From e590c294c797747fc06a8edc3ab668a0e0ae3d7b Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 05:32:40 -0700 Subject: [PATCH 001/127] feat(payments): prepare and authenticate bounded BRC-118 requests --- .github/workflows/conformance.yml | 2 + conformance/META.json | 92 +++- conformance/PARITY_MATRIX.json | 22 +- conformance/runner/scripts/brc118-vectors.py | 96 ++++ conformance/runner/ts/dispatchers/payments.ts | 44 +- conformance/vectors/payments/brc118.json | 168 +++++++ docs/guides/brc118-payments.md | 199 ++++++++ governance/mutation-testing/targets.mjs | 4 +- .../auth-express-middleware/README.md | 12 + .../auth-express-middleware/package.json | 4 +- .../src/__tests/Brc118.vectors.test.ts | 44 ++ .../src/__tests/RawRequestBody.test.ts | 108 ++++ .../src/authMiddlewareHelpers.ts | 3 +- .../auth-express-middleware/src/index.ts | 83 ++- .../src/rawRequestBody.ts | 114 +++++ .../payment-express-middleware/README.md | 13 + .../payment-express-middleware/package.json | 15 +- .../src/__tests/Brc118.integration.test.ts | 355 +++++++++++++ .../src/__tests/MultipartPayment.test.ts | 139 ++++++ .../payment-express-middleware/src/index.ts | 85 +++- .../src/multipartPayment.ts | 114 +++++ .../payment-express-middleware/src/types.ts | 8 + .../test/brc118-browser.mjs | 219 ++++++++ .../test/brc118-client.mjs | 67 +++ packages/sdk/CHANGELOG.md | 11 + packages/sdk/README.md | 19 +- packages/sdk/package.json | 2 +- packages/sdk/src/auth/clients/AuthFetch.ts | 472 ++++++++++++++---- .../auth/clients/__tests/paymentFixtures.ts | 29 ++ .../__tests__/AuthFetch.additional.test.ts | 47 +- .../__tests__/AuthFetch.boundary.test.ts | 74 ++- .../__tests__/AuthFetch.knownTxids.test.ts | 53 +- .../__tests__/AuthFetch.multipart.test.ts | 253 ++++++++++ .../auth/clients/__tests__/AuthFetch.test.ts | 27 +- .../__tests/decodePaymentPayload.test.ts | 29 ++ .../utils/__tests/paymentTransport.test.ts | 83 +++ .../src/auth/utils/decodePaymentPayload.ts | 24 + packages/sdk/src/auth/utils/index.ts | 1 + .../sdk/src/auth/utils/paymentTransport.ts | 254 ++++++++++ .../__tests/BasicBRC29.interop.test.ts | 63 +++ .../sdk/src/remittance/modules/BasicBRC29.ts | 2 +- pnpm-lock.yaml | 9 + scripts/brc100-byte-boundary.test.mjs | 3 +- 43 files changed, 3239 insertions(+), 226 deletions(-) create mode 100644 conformance/runner/scripts/brc118-vectors.py create mode 100644 conformance/vectors/payments/brc118.json create mode 100644 docs/guides/brc118-payments.md create mode 100644 packages/middleware/auth-express-middleware/src/__tests/Brc118.vectors.test.ts create mode 100644 packages/middleware/auth-express-middleware/src/__tests/RawRequestBody.test.ts create mode 100644 packages/middleware/auth-express-middleware/src/rawRequestBody.ts create mode 100644 packages/middleware/payment-express-middleware/src/__tests/Brc118.integration.test.ts create mode 100644 packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts create mode 100644 packages/middleware/payment-express-middleware/src/multipartPayment.ts create mode 100644 packages/middleware/payment-express-middleware/test/brc118-browser.mjs create mode 100644 packages/middleware/payment-express-middleware/test/brc118-client.mjs create mode 100644 packages/sdk/src/auth/clients/__tests/paymentFixtures.ts create mode 100644 packages/sdk/src/auth/clients/__tests__/AuthFetch.multipart.test.ts create mode 100644 packages/sdk/src/auth/utils/__tests/decodePaymentPayload.test.ts create mode 100644 packages/sdk/src/auth/utils/__tests/paymentTransport.test.ts create mode 100644 packages/sdk/src/auth/utils/decodePaymentPayload.ts create mode 100644 packages/sdk/src/auth/utils/paymentTransport.ts create mode 100644 packages/sdk/src/remittance/__tests/BasicBRC29.interop.test.ts diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a8b000442..2347356b3 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -31,6 +31,8 @@ jobs: cache: pnpm - name: Install deps run: pnpm install --frozen-lockfile --ignore-scripts + - name: Verify independent BRC-118 wire and signature vectors + run: python3 conformance/runner/scripts/brc118-vectors.py - name: Build TS runner dependencies run: >- pnpm -r diff --git a/conformance/META.json b/conformance/META.json index 4e39d1b2b..fbe227d60 100644 --- a/conformance/META.json +++ b/conformance/META.json @@ -20,10 +20,20 @@ "sdk.keys.publickey", "sdk.crypto.signature" ], - "BRC-74": ["sdk.transactions.merklepath", "broadcast.merklepath"], - "BRC-77": ["sdk.compat.bsm"], - "BRC-31": ["messaging.brc31.authrite-signature"], - "BRC-29": ["wallet.brc29.payment-derivation", "payments.brc29-payment-protocol"], + "BRC-74": [ + "sdk.transactions.merklepath", + "broadcast.merklepath" + ], + "BRC-77": [ + "sdk.compat.bsm" + ], + "BRC-31": [ + "messaging.brc31.authrite-signature" + ], + "BRC-29": [ + "wallet.brc29.payment-derivation", + "payments.brc29-payment-protocol" + ], "BRC-100": [ "wallet.brc100.getpublickey", "wallet.brc100.createhmac", @@ -55,25 +65,65 @@ "wallet.brc100.getversion", "wallet.storage.adapterconformance" ], - "BRC-121": ["payments.brc121"], - "BRC-26": ["storage.uhrp-http"], - "BRC-167": ["storage.chirp-v1"], - "BRC-62": ["overlay.submit"], - "BRC-22": ["overlay.lookup", "overlay.topicmanagement"], - "BRC-20": ["broadcast.arcsubmit", "broadcast.merklepath"], - "BRC-21": ["sync.gasprotocol"], - "BRC-40": ["sync.brc40"], - "BRC-14": ["sdk.scripts.evaluation"], - "merkle-service": ["broadcast.merkle-service"], - "message-box": ["messaging.messagebox-http"], - "chaintracks-v2": ["sync.chaintracks-v2-http"], - "BRC-141": ["transport.air-gap-optical"], - "BRC-103": ["auth.brc31-handshake", "messaging.authsocket"], - "BRC-104": ["auth.brc31-handshake"] + "BRC-121": [ + "payments.brc121" + ], + "BRC-26": [ + "storage.uhrp-http" + ], + "BRC-167": [ + "storage.chirp-v1" + ], + "BRC-62": [ + "overlay.submit" + ], + "BRC-22": [ + "overlay.lookup", + "overlay.topicmanagement" + ], + "BRC-20": [ + "broadcast.arcsubmit", + "broadcast.merklepath" + ], + "BRC-21": [ + "sync.gasprotocol" + ], + "BRC-40": [ + "sync.brc40" + ], + "BRC-14": [ + "sdk.scripts.evaluation" + ], + "merkle-service": [ + "broadcast.merkle-service" + ], + "message-box": [ + "messaging.messagebox-http" + ], + "chaintracks-v2": [ + "sync.chaintracks-v2-http" + ], + "BRC-141": [ + "transport.air-gap-optical" + ], + "BRC-103": [ + "auth.brc31-handshake", + "messaging.authsocket" + ], + "BRC-104": [ + "auth.brc31-handshake", + "payments.brc118" + ], + "BRC-118": [ + "payments.brc118" + ], + "BRC-105": [ + "payments.brc118" + ] }, "stats": { - "total_files": 77, - "total_vectors": 6699, + "total_files": 78, + "total_vectors": 6705, "last_updated": "2026-09-23" }, "regression_index": { diff --git a/conformance/PARITY_MATRIX.json b/conformance/PARITY_MATRIX.json index 874955ba1..b0f5450d2 100644 --- a/conformance/PARITY_MATRIX.json +++ b/conformance/PARITY_MATRIX.json @@ -4,18 +4,18 @@ "source": "ts-stack conformance corpus", "description": "Machine-readable parity status for cross-language SDK implementations (Go, Rust, Python). Use this to track and drive conformance.", "summary": { - "total_files": 77, - "total_vectors": 6699, - "fully_required_files": 58, + "total_files": 78, + "total_vectors": 6705, + "fully_required_files": 59, "files_with_intended": 17, "files_with_mixed_status": 15, "vectors_by_status": { - "required": 6495, + "required": 6501, "intended": 204, "skipped": 7 }, "by_reason_category": { - "fully_supported": 1283, + "fully_supported": 1289, "governed_vector_skip": 50, "historical_regression": 36, "partial_ts_behavioral_difference": 5116, @@ -145,6 +145,18 @@ "reason_category": "fully_supported", "categories": [] }, + { + "path": "payments/brc118.json", + "id": "payments.brc118", + "total_vectors": 6, + "file_level_parity": "required", + "effective_status": "required", + "required_count": 6, + "intended_count": 0, + "skipped_count": 0, + "reason_category": "fully_supported", + "categories": [] + }, { "path": "payments/brc121.json", "id": "payments.brc121", diff --git a/conformance/runner/scripts/brc118-vectors.py b/conformance/runner/scripts/brc118-vectors.py new file mode 100644 index 000000000..6f0b04ff6 --- /dev/null +++ b/conformance/runner/scripts/brc118-vectors.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Independent stdlib RFC 7578 reader, wire writer, and BRC-104 preimage oracle. + +Run without arguments to verify the checked-in corpus. --write regenerates it. +This is an independent fixture oracle, not a claimed external wallet integration. +""" +import base64 +import json +import struct +import sys +from email import policy +from email.parser import BytesParser +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +DESTINATION = ROOT / 'conformance/vectors/payments/brc118.json' +REVISION = '2b959b13f1f73040d13cc4eb14edbfc376f8010b' +BOUNDARY = '----BsvPayment00112233445566778899aabbccddeeff' +PAYMENT = '{"derivationPrefix":"AA==","derivationSuffix":"AQ==","transaction":"AQID"}' + + +def varint(number): + if number < 0: + return b'\xff' + struct.pack(', + expected: Record +): Promise { + const payload = + input.payload_base64 === null + ? undefined + : { + bytes: new Uint8Array(Buffer.from(input.payload_base64 as string, 'base64')), + contentType: input.payload_content_type as string + } + const encoded = buildMultipartPayment( + input.payment_json as string, + payload, + 1024 * 1024, + input.boundary as string + ) + expect(Buffer.from(encoded.body).toString('base64')).toBe(expected.body_base64) + const client = new AuthFetch({} as WalletInterface) as unknown as { + serializeRequest( + method: string, + headers: Record, + body: Uint8Array, + url: URL, + nonce: number[] + ): Promise + } + const writer = await client.serializeRequest( + input.method as string, + input.headers as Record, + encoded.body, + new URL(`https://fixture.invalid${input.path as string}${input.query as string}`), + Array.from(Buffer.from(input.request_id_hex as string, 'hex')) + ) + expect(toHex(writer.toArray())).toBe(expected.request_preimage_hex) +} // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -336,7 +376,7 @@ function dispatchBRC121(input: Record, expected: Record = ['brc29-payment-protocol', 'brc121'] +export const categories: ReadonlyArray = ['brc29-payment-protocol', 'brc121', 'brc118'] export function dispatch( category: string, @@ -344,6 +384,8 @@ export function dispatch( expected: Record ): void | Promise { switch (category) { + case 'brc118': + return dispatchBRC118(input, expected) case 'brc29-payment-protocol': return dispatchBRC29PaymentProtocol(input, expected) case 'brc121': diff --git a/conformance/vectors/payments/brc118.json b/conformance/vectors/payments/brc118.json new file mode 100644 index 000000000..db38431fb --- /dev/null +++ b/conformance/vectors/payments/brc118.json @@ -0,0 +1,168 @@ +{ + "$schema": "../../schema/vector.schema.json", + "id": "payments.brc118", + "name": "BRC-118 Multipart Transport and BRC-104 Request Preimages", + "version": "1.0.0", + "brc": ["BRC-104", "BRC-105", "BRC-118"], + "reference_impl": "@bsv/sdk; independent Python stdlib oracle", + "parity_class": "required", + "notes": "Reviewed BRCs commit 2b959b13f1f73040d13cc4eb14edbfc376f8010b; peer-to-peer/0104.md, payments/0105.md, payments/0118.md. Payment fields are synthetic transport bytes, not a spendable transaction. Non-multipart released signature preimages remain unchanged.", + "vectors": [ + { + "id": "payments.brc118.json-whitespace-utf8", + "description": "Exact multipart bytes and authenticated preimage: json-whitespace-utf8", + "input": { + "payment_json": "{\"derivationPrefix\":\"AA==\",\"derivationSuffix\":\"AQ==\",\"transaction\":\"AQID\"}", + "boundary": "----BsvPayment00112233445566778899aabbccddeeff", + "quoted_boundary": false, + "payload_base64": "eyAic25vdyI6ICLpm6oiIH0K", + "payload_content_type": "application/json; charset=utf-8", + "method": "POST", + "path": "/paid", + "query": "?q=%E9%9B%AA&order=1", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "headers": { + "authorization": "Bearer conformance-fixture", + "content-type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "x-bsv-example": "fixture" + } + }, + "expected": { + "body_base64": "LS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9IngtYnN2LXBheW1lbnQiDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24NCg0KeyJkZXJpdmF0aW9uUHJlZml4IjoiQUE9PSIsImRlcml2YXRpb25TdWZmaXgiOiJBUT09IiwidHJhbnNhY3Rpb24iOiJBUUlEIn0NCi0tLS0tLUJzdlBheW1lbnQwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJib2R5Ig0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi9qc29uOyBjaGFyc2V0PXV0Zi04DQoNCnsgInNub3ciOiAi6ZuqIiB9Cg0KLS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmLS0NCg==", + "content_type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "request_preimage_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f04504f5354052f70616964143f713d254539253942254141266f726465723d31030d617574686f72697a6174696f6e1a42656172657220636f6e666f726d616e63652d666978747572650c636f6e74656e742d747970654c6d756c7469706172742f666f726d2d646174613b20626f756e646172793d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d782d6273762d6578616d706c650766697874757265fdae012d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22782d6273762d7061796d656e74220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e0d0a0d0a7b2264657269766174696f6e507265666978223a2241413d3d222c2264657269766174696f6e537566666978223a2241513d3d222c227472616e73616374696f6e223a2241514944227d0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22626f6479220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e3b20636861727365743d7574662d380d0a0d0a7b2022736e6f77223a2022e99baa22207d0a0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566662d2d0d0a" + }, + "tags": ["brc-118", "exact-bytes", "independent-python-oracle"] + }, + { + "id": "payments.brc118.binary-nul", + "description": "Exact multipart bytes and authenticated preimage: binary-nul", + "input": { + "payment_json": "{\"derivationPrefix\":\"AA==\",\"derivationSuffix\":\"AQ==\",\"transaction\":\"AQID\"}", + "boundary": "----BsvPayment00112233445566778899aabbccddeeff", + "quoted_boundary": false, + "payload_base64": "AID/DQoA", + "payload_content_type": "application/octet-stream", + "method": "POST", + "path": "/paid", + "query": "?q=%E9%9B%AA&order=1", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "headers": { + "authorization": "Bearer conformance-fixture", + "content-type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "x-bsv-example": "fixture" + } + }, + "expected": { + "body_base64": "LS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9IngtYnN2LXBheW1lbnQiDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24NCg0KeyJkZXJpdmF0aW9uUHJlZml4IjoiQUE9PSIsImRlcml2YXRpb25TdWZmaXgiOiJBUT09IiwidHJhbnNhY3Rpb24iOiJBUUlEIn0NCi0tLS0tLUJzdlBheW1lbnQwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJib2R5Ig0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW0NCg0KAID/DQoADQotLS0tLS1Cc3ZQYXltZW50MDAxMTIyMzM0NDU1NjY3Nzg4OTlhYWJiY2NkZGVlZmYtLQ0K", + "content_type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "request_preimage_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f04504f5354052f70616964143f713d254539253942254141266f726465723d31030d617574686f72697a6174696f6e1a42656172657220636f6e666f726d616e63652d666978747572650c636f6e74656e742d747970654c6d756c7469706172742f666f726d2d646174613b20626f756e646172793d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d782d6273762d6578616d706c650766697874757265fd9b012d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22782d6273762d7061796d656e74220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e0d0a0d0a7b2264657269766174696f6e507265666978223a2241413d3d222c2264657269766174696f6e537566666978223a2241513d3d222c227472616e73616374696f6e223a2241514944227d0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22626f6479220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6f637465742d73747265616d0d0a0d0a0080ff0d0a000d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566662d2d0d0a" + }, + "tags": ["brc-118", "exact-bytes", "independent-python-oracle"] + }, + { + "id": "payments.brc118.empty", + "description": "Exact multipart bytes and authenticated preimage: empty", + "input": { + "payment_json": "{\"derivationPrefix\":\"AA==\",\"derivationSuffix\":\"AQ==\",\"transaction\":\"AQID\"}", + "boundary": "----BsvPayment00112233445566778899aabbccddeeff", + "quoted_boundary": false, + "payload_base64": "", + "payload_content_type": "text/plain", + "method": "POST", + "path": "/paid", + "query": "?q=%E9%9B%AA&order=1", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "headers": { + "authorization": "Bearer conformance-fixture", + "content-type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "x-bsv-example": "fixture" + } + }, + "expected": { + "body_base64": "LS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9IngtYnN2LXBheW1lbnQiDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24NCg0KeyJkZXJpdmF0aW9uUHJlZml4IjoiQUE9PSIsImRlcml2YXRpb25TdWZmaXgiOiJBUT09IiwidHJhbnNhY3Rpb24iOiJBUUlEIn0NCi0tLS0tLUJzdlBheW1lbnQwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJib2R5Ig0KQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNCg0KLS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmLS0NCg==", + "content_type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "request_preimage_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f04504f5354052f70616964143f713d254539253942254141266f726465723d31030d617574686f72697a6174696f6e1a42656172657220636f6e666f726d616e63652d666978747572650c636f6e74656e742d747970654c6d756c7469706172742f666f726d2d646174613b20626f756e646172793d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d782d6273762d6578616d706c650766697874757265fd87012d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22782d6273762d7061796d656e74220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e0d0a0d0a7b2264657269766174696f6e507265666978223a2241413d3d222c2264657269766174696f6e537566666978223a2241513d3d222c227472616e73616374696f6e223a2241514944227d0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22626f6479220d0a436f6e74656e742d547970653a20746578742f706c61696e0d0a0d0a0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566662d2d0d0a" + }, + "tags": ["brc-118", "exact-bytes", "independent-python-oracle"] + }, + { + "id": "payments.brc118.absent", + "description": "Exact multipart bytes and authenticated preimage: absent", + "input": { + "payment_json": "{\"derivationPrefix\":\"AA==\",\"derivationSuffix\":\"AQ==\",\"transaction\":\"AQID\"}", + "boundary": "----BsvPayment00112233445566778899aabbccddeeff", + "quoted_boundary": false, + "payload_base64": null, + "payload_content_type": null, + "method": "POST", + "path": "/paid", + "query": "?q=%E9%9B%AA&order=1", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "headers": { + "authorization": "Bearer conformance-fixture", + "content-type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "x-bsv-example": "fixture" + } + }, + "expected": { + "body_base64": "LS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9IngtYnN2LXBheW1lbnQiDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24NCg0KeyJkZXJpdmF0aW9uUHJlZml4IjoiQUE9PSIsImRlcml2YXRpb25TdWZmaXgiOiJBUT09IiwidHJhbnNhY3Rpb24iOiJBUUlEIn0NCi0tLS0tLUJzdlBheW1lbnQwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZi0tDQo=", + "content_type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "request_preimage_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f04504f5354052f70616964143f713d254539253942254141266f726465723d31030d617574686f72697a6174696f6e1a42656172657220636f6e666f726d616e63652d666978747572650c636f6e74656e742d747970654c6d756c7469706172742f666f726d2d646174613b20626f756e646172793d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d782d6273762d6578616d706c650766697874757265fd0a012d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22782d6273762d7061796d656e74220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e0d0a0d0a7b2264657269766174696f6e507265666978223a2241413d3d222c2264657269766174696f6e537566666978223a2241513d3d222c227472616e73616374696f6e223a2241514944227d0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566662d2d0d0a" + }, + "tags": ["brc-118", "exact-bytes", "independent-python-oracle"] + }, + { + "id": "payments.brc118.nested-multipart", + "description": "Exact multipart bytes and authenticated preimage: nested-multipart", + "input": { + "payment_json": "{\"derivationPrefix\":\"AA==\",\"derivationSuffix\":\"AQ==\",\"transaction\":\"AQID\"}", + "boundary": "----BsvPayment00112233445566778899aabbccddeeff", + "quoted_boundary": false, + "payload_base64": "LS1pbm5lcg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJmaWVsZCINCg0KdmFsdWUNCi0taW5uZXItLQ0K", + "payload_content_type": "multipart/form-data; boundary=inner", + "method": "POST", + "path": "/paid", + "query": "?q=%E9%9B%AA&order=1", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "headers": { + "authorization": "Bearer conformance-fixture", + "content-type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "x-bsv-example": "fixture" + } + }, + "expected": { + "body_base64": "LS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9IngtYnN2LXBheW1lbnQiDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24NCg0KeyJkZXJpdmF0aW9uUHJlZml4IjoiQUE9PSIsImRlcml2YXRpb25TdWZmaXgiOiJBUT09IiwidHJhbnNhY3Rpb24iOiJBUUlEIn0NCi0tLS0tLUJzdlBheW1lbnQwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJib2R5Ig0KQ29udGVudC1UeXBlOiBtdWx0aXBhcnQvZm9ybS1kYXRhOyBib3VuZGFyeT1pbm5lcg0KDQotLWlubmVyDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZpZWxkIg0KDQp2YWx1ZQ0KLS1pbm5lci0tDQoNCi0tLS0tLUJzdlBheW1lbnQwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZi0tDQo=", + "content_type": "multipart/form-data; boundary=----BsvPayment00112233445566778899aabbccddeeff", + "request_preimage_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f04504f5354052f70616964143f713d254539253942254141266f726465723d31030d617574686f72697a6174696f6e1a42656172657220636f6e666f726d616e63652d666978747572650c636f6e74656e742d747970654c6d756c7469706172742f666f726d2d646174613b20626f756e646172793d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d782d6273762d6578616d706c650766697874757265fdeb012d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22782d6273762d7061796d656e74220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e0d0a0d0a7b2264657269766174696f6e507265666978223a2241413d3d222c2264657269766174696f6e537566666978223a2241513d3d222c227472616e73616374696f6e223a2241514944227d0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22626f6479220d0a436f6e74656e742d547970653a206d756c7469706172742f666f726d2d646174613b20626f756e646172793d696e6e65720d0a0d0a2d2d696e6e65720d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d226669656c64220d0a0d0a76616c75650d0a2d2d696e6e65722d2d0d0a0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566662d2d0d0a" + }, + "tags": ["brc-118", "exact-bytes", "independent-python-oracle"] + }, + { + "id": "payments.brc118.quoted-boundary", + "description": "Exact multipart bytes and authenticated preimage: quoted-boundary", + "input": { + "payment_json": "{\"derivationPrefix\":\"AA==\",\"derivationSuffix\":\"AQ==\",\"transaction\":\"AQID\"}", + "boundary": "----BsvPayment00112233445566778899aabbccddeeff", + "quoted_boundary": true, + "payload_base64": "cXVvdGVk", + "payload_content_type": "text/plain", + "method": "POST", + "path": "/paid", + "query": "?q=%E9%9B%AA&order=1", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "headers": { + "authorization": "Bearer conformance-fixture", + "content-type": "multipart/form-data; boundary=\"----BsvPayment00112233445566778899aabbccddeeff\"", + "x-bsv-example": "fixture" + } + }, + "expected": { + "body_base64": "LS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9IngtYnN2LXBheW1lbnQiDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24NCg0KeyJkZXJpdmF0aW9uUHJlZml4IjoiQUE9PSIsImRlcml2YXRpb25TdWZmaXgiOiJBUT09IiwidHJhbnNhY3Rpb24iOiJBUUlEIn0NCi0tLS0tLUJzdlBheW1lbnQwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJib2R5Ig0KQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNCnF1b3RlZA0KLS0tLS0tQnN2UGF5bWVudDAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmLS0NCg==", + "content_type": "multipart/form-data; boundary=\"----BsvPayment00112233445566778899aabbccddeeff\"", + "request_preimage_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f04504f5354052f70616964143f713d254539253942254141266f726465723d31030d617574686f72697a6174696f6e1a42656172657220636f6e666f726d616e63652d666978747572650c636f6e74656e742d747970654e6d756c7469706172742f666f726d2d646174613b20626f756e646172793d222d2d2d2d4273765061796d656e743030313132323333343435353636373738383939616162626363646465656666220d782d6273762d6578616d706c650766697874757265fd8d012d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22782d6273762d7061796d656e74220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e0d0a0d0a7b2264657269766174696f6e507265666978223a2241413d3d222c2264657269766174696f6e537566666978223a2241513d3d222c227472616e73616374696f6e223a2241514944227d0d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566660d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d22626f6479220d0a436f6e74656e742d547970653a20746578742f706c61696e0d0a0d0a71756f7465640d0a2d2d2d2d2d2d4273765061796d656e7430303131323233333434353536363737383839396161626263636464656566662d2d0d0a" + }, + "tags": ["brc-118", "exact-bytes", "independent-python-oracle"] + } + ] +} diff --git a/docs/guides/brc118-payments.md b/docs/guides/brc118-payments.md new file mode 100644 index 000000000..0c53df844 --- /dev/null +++ b/docs/guides/brc118-payments.md @@ -0,0 +1,199 @@ +--- +id: guide-brc118-payments +title: 'BRC-118 Authenticated Multipart Payments' +kind: guide +version: '1.0.0' +last_updated: '2026-09-23' +last_verified: '2026-09-23' +review_cadence_days: 30 +status: beta +tags: [guide, payments, authentication, brc-105, brc-118] +--- + +# BRC-118 authenticated multipart payments + +BRC-118 carries a BRC-105 payment in an authenticated multipart request when +the payment BEEF would exceed a header budget. Small payments continue to use +`x-bsv-payment`. Deploy receiver support before enabling multipart clients. +The SDK, auth middleware and payment middleware must all support this profile. + +## Enable the receiver + +Install raw-byte authentication before any body parser. The middleware verifies +the complete transmitted body and the exact multipart Content-Type, including +its boundary, before extracting payment or application data. + +```ts +import express from 'express' +import { createAuthMiddleware } from '@bsv/auth-express-middleware' +import { createPaymentMiddleware } from '@bsv/payment-express-middleware' +import type { WalletInterface } from '@bsv/sdk' + +export function paidApp(wallet: WalletInterface) { + const app = express() + app.use(createAuthMiddleware({ wallet, captureRawBody: true })) + app.use( + createPaymentMiddleware({ + wallet, + enableMultipart: true, + calculateRequestPrice: () => 1 + }) + ) + app.post('/paid', (req, res) => res.json({ received: req.body })) + return app +} +``` + +`captureRawBody` and `enableMultipart` are opt-in. A verified 402 advertises +`x-bsv-payment-transports: header,multipart` only when both are active. With +either absent, the payment middleware advertises header support only. An older +server with no advertisement is also treated as header-only. + +The existing amount, BRC-29 derivation, BEEF validation, wallet internalization +and transaction-ID replay checks apply to either transport. Configure a shared, +durable atomic replay store across server processes; the default store is +bounded and process-local. A wallet exception retains the released 400 `ERR_PAYMENT_FAILED` response; a +replay-store failure returns 503. Neither proves that wallet work was rolled +back, and neither carries a new payment challenge. Reconcile the submitted +transaction before paying again. Payment transport is not durable result redelivery. + +## Preserve application bytes + +The wrapper contains one JSON `x-bsv-payment` part and at most one `body` part. +The latter holds the original bytes and media type. The route receives the +inner Content-Type and `req.rawBody`; `req.body` is decoded for JSON, text and +URL-encoded media types, and remains bytes for binary or nested multipart data. +JSON whitespace and UTF-8 bytes survive in `rawBody`. An empty part is distinct +from an absent part. The route never receives the outer payment in its body. + +Boundaries may be quoted or unquoted, but must be a single 1–70 character MIME +token. Framing uses CRLF. Duplicate parts or part headers, conflicting header +and body payments, unexpected paid-wrapper parts, unsupported part headers, +truncation and epilogues are rejected. An original multipart application payload +is an opaque inner body, not recursively parsed. Its initial unpaid request must +fit the receiver's bounded multipart profile (at most 128 parts, ASCII part +headers); applications needing a broader upload format should use a separate +upload route and pay for its resulting resource. + +Existing non-multipart signature preimages retain their released normalization. +Multipart authenticates the exact Content-Type value. There is no second, +weaker verification attempt with the boundary removed. + +## Prepare a payment before broadcasting + +`AuthFetch` snapshots the original request, creates a payment with +`noSend: true`, validates its actual Atomic BEEF/output and serializes the final +authenticated request before submitting it with `sendWith`. The wallet must +honor the BRC-100 prepare/submit contract and implement `abortAction`; wallets +that cannot do so must not use this automatic payment path. There is no fallback +to creating and immediately broadcasting a replacement transaction. + +```ts +import { AuthFetch, WalletClient } from '@bsv/sdk' + +const client = new AuthFetch(new WalletClient()) +const response = await client.fetch('https://example.com/paid', { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: '{ "message": "hello" }', + paymentTransport: { maxPaymentHeaderBytes: 4096 }, + signal: new AbortController().signal +}) +``` + +Small payments use a header when advertised. Larger payments require multipart +support. The original method, URL and query are preserved. Fetch forbids bodies +on GET/HEAD, so payments that require multipart on those methods fail before +broadcast; choose a server-supported POST route explicitly. Oversized +header-only attempts are refused, rather than relying on the standard's +recommended header fallback to deliver an already broadcast payment. + +Pass replayable, owned bytes for binary and original multipart bodies. Serialize +native FormData once with a Request, retain its generated Content-Type and read +its arrayBuffer before calling AuthFetch. Text-only FormData retains the legacy +URL-encoded behavior; file-bearing FormData and streams are rejected instead of +silently dropping files or changing signed bytes. + +Transport changes and bounded delivery retries reuse the same transaction. +A changed amount, derivation prefix or authenticated recipient stops automatic +payment and requires reconciliation. Prepared reservations are aborted on +refusal or cancellation where the wallet allows it. Once submission starts, a +lost acknowledgement is uncertain: it is not safe to abort or create another +payment automatically. Cancellation stops waiting and pending listeners; +already dispatched network or wallet work can still complete. + +`PaymentTransportError` has a permanent `code`, `retryable: false` and, when +available, a payment transaction ID and `prepared`, `submitted` or `uncertain` +state. A prepared refusal can additionally report `aborted`. HTTP 413/431 errors +are terminal and distinguish authenticated server replies from unsigned proxy +failures through `authenticated` and `httpStatus`. Reconcile uncertain outcomes +using the wallet and service before starting a new payment. No durable refund or +paid-result journal is implied. + +## Bound resource use and expose negotiation to browsers + +| Bound | Default | +| -------------------------------------------------- | ---------------------------------------------- | +| SDK payment header selection | 8,192 bytes | +| SDK aggregate request headers | 16 KiB, including a 4 KiB auth/framing reserve | +| SDK and payment middleware payment JSON | 4 MiB | +| SDK and payment middleware complete body | 7 MiB | +| Auth middleware complete request encoding | 8 MiB | +| Raw receiver retained bodies | 64 MiB aggregate per middleware instance | +| Raw receiver pending requests / collection timeout | 1,000 / 30 seconds | +| Multipart part headers | 2,048 bytes per part | + +These are bounds, not promises about an intermediary. Set lower client budgets +for a known proxy, and align proxy, auth and payment middleware limits. The raw +collector rejects compressed bodies, malformed lengths, premature disconnects, +slow input, exhausted capacity and oversized streams before authentication. +Body collection cannot be disabled or made unbounded in raw mode. Multipart +removes header pressure, not body limits. + +Keep the service's credential-free cross-origin access policy. Handle OPTIONS +before authentication, allow the request Content-Type and `x-bsv-auth-*`/ +`x-bsv-payment` headers, and expose the existing authentication response headers +plus `x-bsv-payment-transports`, `x-bsv-payment-version`, +`x-bsv-payment-satoshis-required`, `x-bsv-payment-derivation-prefix` and the optional +`x-bsv-payment-known-txids`. Multipart requires no credentialed browser mode or +new origin allowlist. Use explicit names in CORS libraries that do not support +header-prefix matching. + +## Compatibility and evidence + +The profile pins BRC-104/105/118 at BRCs revision +[`2b959b13f1f73040d13cc4eb14edbfc376f8010b`](https://github.com/bsv-blockchain/BRCs/tree/2b959b13f1f73040d13cc4eb14edbfc376f8010b). +Six shared wire/preimage fixtures under `conformance/vectors/payments/brc118.json` +are independently generated and read by Python's standard-library MIME parser. +The SDK writer, auth verifier and payment parser check the same fixtures. Run +`python3 conformance/runner/scripts/brc118-vectors.py` to verify the independent +oracle. Composed HTTP tests use two independent wallet identities, real mutual +authentication and enforced proxy header/body limits; they do not spend funds. + +External implementation availability is not an interoperability claim. The +reviewed [Python implementation](https://github.com/Calgooon/codex-x402/tree/2f9f89b7769bb05a3dc81c9e287e16f925773f5b) +has a multipart writer but strips Content-Type parameters in its signing path +and omits empty body parts. Its complete authenticated path therefore does not +match these boundary-binding vectors. No live Rust/Workers peer or external +owner sign-off is claimed. + +`@bsv/402-pay` implements the separate BRC-121 header protocol +(`x-bsv-beef`, sender, nonce, time and vout); it does not use BRC-105 AuthFetch +negotiation. Its [guide](./http-402-payments.md) and wire contract remain separate. +PeerPay MessageBox receive compatibility likewise does not increase a relay's +encrypted-envelope limit or make an already broadcast oversized message safe. + +The native Chromium gate exercises credential-free cross-origin preflights, +large binary and exact-JSON payments, and a header-only server. Hiding the signed +negotiation header through CORS invalidates the response signature before any +payment preparation; expose the complete signed response header set. + +Published SDK 2.8.0 was also tested in the same native-browser fixture against +both new receiver configurations (multipart enabled and disabled). Its small +header payment is accepted once with the exact application body. This uses the +registry tarball after SHA-512 integrity verification, independently from the +workspace SDK build. To repeat the additional historical-client probe, extract +that verified package and point `BRC118_LEGACY_SDK_MODULE` at its +`dist/esm/mod.js` when running the payment package's `test:browser`; the complete +current-client gate still runs first. This does not claim that a legacy client +can send payments above the legacy header limit. diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 6d9f880da..5f65cb255 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -423,7 +423,7 @@ export function buildMutationTargets(repositoryRoot) { manifest: 'packages/middleware/auth-express-middleware/package.json', propertyTest: 'packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts', - mutate: ['src/authMiddlewareHelpers.ts:96-103', 'src/authMiddlewareHelpers.ts:186-213'], + mutate: ['src/authMiddlewareHelpers.ts:97-104', 'src/authMiddlewareHelpers.ts:187-214'], ...jestTarget('jest.config.js', ['/src/__tests/authMiddlewareHelpers*.test.ts']) }, 'payment-replay': { @@ -431,7 +431,7 @@ export function buildMutationTargets(repositoryRoot) { manifest: 'packages/middleware/payment-express-middleware/package.json', propertyTest: 'packages/middleware/payment-express-middleware/src/__tests/PaymentReplayStore.property.test.ts', - mutate: ['src/index.ts:27-44'], + mutate: ['src/index.ts:30-47'], ...jestTarget('jest.config.js', ['/src/__tests/PaymentReplayStore*.test.ts']) }, 'wallet-script-encoding': { diff --git a/packages/middleware/auth-express-middleware/README.md b/packages/middleware/auth-express-middleware/README.md index b01510660..9f7c009da 100644 --- a/packages/middleware/auth-express-middleware/README.md +++ b/packages/middleware/auth-express-middleware/README.md @@ -9,6 +9,18 @@ The current release preserves BRC-100 byte fields across supported JSON and byte-array forms, snapshots handshake messages before asynchronous work, and rejects parsed bodies that cannot be represented without losing semantics. +## Exact-byte multipart authentication + +Set `captureRawBody: true` and install this middleware **before body parsers** +when composing BRC-118 payments. It collects bounded raw bytes, authenticates the +whole body and exact multipart Content-Type (including boundary), then exposes +`req.rawBody` and the verified `req.auth.supportsMultipart` marker. Existing +non-multipart signature preimages and the default parsed-body integration remain +compatible. Pair with payment middleware `enableMultipart: true`; raw auth alone +does not advertise payment support. Collection enforces request size, aggregate +memory, pending-request and timeout limits before authentication. See the +[BRC-118 guide](../../../docs/guides/brc118-payments.md) for limits, CORS and migration. + ## Requirements - Node.js 22 or newer diff --git a/packages/middleware/auth-express-middleware/package.json b/packages/middleware/auth-express-middleware/package.json index c8299e8f3..af652ebaa 100644 --- a/packages/middleware/auth-express-middleware/package.json +++ b/packages/middleware/auth-express-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/auth-express-middleware", - "version": "2.2.5", + "version": "2.3.0", "sideEffects": false, "engines": { "node": ">=22" @@ -86,7 +86,7 @@ "mime-types": "^3.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.7.1", + "@bsv/sdk": "^2.9.0", "@types/express": ">=4.17.0 <6", "express": ">=4.18.0 <6" }, diff --git a/packages/middleware/auth-express-middleware/src/__tests/Brc118.vectors.test.ts b/packages/middleware/auth-express-middleware/src/__tests/Brc118.vectors.test.ts new file mode 100644 index 000000000..12670a4d8 --- /dev/null +++ b/packages/middleware/auth-express-middleware/src/__tests/Brc118.vectors.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { Utils } from '@bsv/sdk' +import { + writeBodyToWriter, + writeRequestHeadersToWriter, + writeUrlToWriter +} from '../authMiddlewareHelpers.js' + +const corpus = JSON.parse( + readFileSync(resolve(process.cwd(), '../../../conformance/vectors/payments/brc118.json'), 'utf8') +) as { + vectors: Array<{ + id: string + input: { + request_id_hex: string + method: string + path: string + query: string + headers: Record + } + expected: { body_base64: string; request_preimage_hex: string } + }> +} + +describe('BRC-118 independently generated request preimages', () => { + it.each(corpus.vectors)('$id', vector => { + const writer = new Utils.Writer() + writer.write(Utils.toArray(vector.input.request_id_hex, 'hex')) + writer.writeVarIntNum(vector.input.method.length) + writer.write(Utils.toArray(vector.input.method, 'utf8')) + writeUrlToWriter( + new URL(`https://fixture.invalid${vector.input.path}${vector.input.query}`), + writer + ) + const req = { + headers: vector.input.headers, + body: Buffer.from(vector.expected.body_base64, 'base64') + } as never + writeRequestHeadersToWriter(req, writer) + writeBodyToWriter(req, writer) + expect(Utils.toHex(writer.toArray())).toBe(vector.expected.request_preimage_hex) + }) +}) diff --git a/packages/middleware/auth-express-middleware/src/__tests/RawRequestBody.test.ts b/packages/middleware/auth-express-middleware/src/__tests/RawRequestBody.test.ts new file mode 100644 index 000000000..e4f54d49a --- /dev/null +++ b/packages/middleware/auth-express-middleware/src/__tests/RawRequestBody.test.ts @@ -0,0 +1,108 @@ +import { PassThrough } from 'node:stream' +import { EventEmitter } from 'node:events' +import type { Request, Response } from 'express' +import { RawRequestBodyReader } from '../rawRequestBody.js' + +function request(headers: Record = {}) { + return Object.assign(new PassThrough(), { headers, body: undefined }) as unknown as Request +} +function response() { + return new EventEmitter() as unknown as Response +} + +describe('bounded pre-auth raw request collection', () => { + it('retains exact raw bytes without extracting or parsing payment data', async () => { + const reader = new RawRequestBodyReader(100, 1000, 2) + const req = request() + const res = response() + const done = reader.capture(req, res) + ;(req as unknown as PassThrough).end(Buffer.from('{ "value": 1 }\n')) + await done + expect(req.body).toEqual(Buffer.from('{ "value": 1 }\n')) + expect(reader.hasCaptured(req)).toBe(true) + res.emit('finish') + }) + + it.each([ + [{ 'content-length': '101' }, 413], + [{ 'content-length': '-1' }, 400], + [{ 'content-length': '9007199254740992' }, 400], + [{ 'content-encoding': 'gzip' }, 415] + ])('refuses an invalid or excessive declared body %#', async (headers, status) => { + const reader = new RawRequestBodyReader(100, 1000, 2) + await expect( + reader.capture(request(headers as Record), response()) + ).rejects.toMatchObject({ status }) + }) + + it('bounds streamed bytes independently of Content-Length and releases failed capacity', async () => { + const reader = new RawRequestBodyReader(3, 1000, 1) + const first = request() + const failure = expect(reader.capture(first, response())).rejects.toMatchObject({ status: 413 }) + ;(first as unknown as PassThrough).end(Buffer.from('four')) + await failure + const second = request() + const res = response() + const done = reader.capture(second, res) + ;(second as unknown as PassThrough).end(Buffer.from('ok')) + await done + res.emit('finish') + }) + + it('keeps completed-but-unanswered bodies inside both aggregate and request-count budgets', async () => { + const reader = new RawRequestBodyReader(8, 1000, 2, 8) + const first = request() + const firstRes = response() + const done = reader.capture(first, firstRes) + ;(first as unknown as PassThrough).end(Buffer.from('12345678')) + await done + const second = request() + const failure = expect(reader.capture(second, response())).rejects.toMatchObject({ + status: 503 + }) + ;(second as unknown as PassThrough).end(Buffer.from('a')) + await failure + firstRes.emit('finish') + const third = request() + const thirdRes = response() + const thirdDone = reader.capture(third, thirdRes) + ;(third as unknown as PassThrough).end(Buffer.from('12345678')) + await thirdDone + thirdRes.emit('close') + }) + + it('bounds slow uploads, disconnects, and pending requests without leaking listeners', async () => { + jest.useFakeTimers() + try { + const reader = new RawRequestBodyReader(100, 25, 1) + const slow = request() + const failure = expect(reader.capture(slow, response())).rejects.toMatchObject({ + status: 408 + }) + await expect(reader.capture(request(), response())).rejects.toMatchObject({ status: 503 }) + await jest.advanceTimersByTimeAsync(25) + await failure + expect(slow.listenerCount('data')).toBe(0) + const disconnected = request() + const res = response() + const aborted = expect(reader.capture(disconnected, res)).rejects.toMatchObject({ + status: 400 + }) + res.emit('close') + await aborted + expect(disconnected.listenerCount('data')).toBe(0) + expect(jest.getTimerCount()).toBe(0) + } finally { + jest.useRealTimers() + } + }) + + it('requires installation before a parser consumes the request', async () => { + const reader = new RawRequestBodyReader(100, 1000, 1) + const req = request() + req.body = { already: 'parsed' } + await expect(reader.capture(req, response())).rejects.toMatchObject({ status: 400 }) + expect(reader.hasCaptured(req)).toBe(false) + expect(() => new RawRequestBodyReader(-1, 1000, 1)).toThrow('finite') + }) +}) diff --git a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts index 349d5372c..6274f30e4 100644 --- a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts +++ b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts @@ -1,3 +1,4 @@ +import { authenticatedContentType } from '@bsv/sdk/auth/utils/paymentTransport' import { Writer, toArray } from '@bsv/sdk/primitives/utils' import { Request } from 'express' import { stringifyBRC100 } from '@bsv/sdk' @@ -127,7 +128,7 @@ export function writeRequestHeadersToWriter(req: Request, writer: Writer): void if (typeof v !== 'string') { throw new TypeError('Signed request headers must have one exact string value.') } - const headerValue = k === 'content-type' ? v.split(';')[0].trim() : v + const headerValue = k === 'content-type' ? authenticatedContentType(v) : v includedHeaders.push([k, headerValue]) } } diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index 37149b50d..ec397b48c 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -1,3 +1,6 @@ +import { RawRequestBodyReader, RawRequestBodyError } from './rawRequestBody.js' +import { isMultipartPaymentType } from '@bsv/sdk/auth/utils/paymentTransport' +import { decodePaymentPayload } from '@bsv/sdk/auth/utils/decodePaymentPayload' import { Reader, Writer, toArray, toBase64, toHex, toUTF8 } from '@bsv/sdk/primitives/utils' import fs from 'node:fs' import path from 'node:path' @@ -95,7 +98,11 @@ export interface AuthTransportLimits { export interface AuthRequest extends Request { auth?: { identityKey: PubKeyHex + /** Raw-byte receiver support; set only after successful mutual authentication. */ + supportsMultipart?: boolean } + /** Exact authenticated application body. Payment middleware replaces this with the inner body. */ + rawBody?: Uint8Array } export interface CertificateApprovalStore { @@ -136,6 +143,8 @@ export class InMemoryCertificateApprovalStore implements CertificateApprovalStor // Developers may optionally provide a handler for incoming certificates. export interface AuthMiddlewareOptions { wallet: WalletInterface + /** Collect bounded raw bytes before auth. Install before any body parser. Required for BRC-118. */ + captureRawBody?: boolean // Optional session store. Default is in-process synchronous `SessionManager`. // Pass an `AsyncSessionManager` (Redis/SQL-backed, etc.) to share state // across load-balanced instances; Peer awaits internally so both work. @@ -1525,7 +1534,10 @@ export class ExpressTransport implements Transport { ;(res as any).__set = res.set ;(res as any).set = (keyOrHeaders: string | Record, value?: string) => { - ;(res as any).__set.call(res, keyOrHeaders, value) + // Express distinguishes set(object) from set(name, value) by argument + // count. Passing an explicit undefined breaks the object overload. + if (typeof keyOrHeaders === 'string') (res as any).__set.call(res, keyOrHeaders, value) + else (res as any).__set.call(res, keyOrHeaders) wrapper.set(keyOrHeaders, value) return res } @@ -2045,6 +2057,9 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions): RequestHan if (allowUnauthenticated !== undefined && typeof allowUnauthenticated !== 'boolean') { throw new TypeError('allowUnauthenticated must be a boolean.') } + if (options.captureRawBody !== undefined && typeof options.captureRawBody !== 'boolean') { + throw new TypeError('captureRawBody must be a boolean.') + } if (logLevel !== undefined && !(['debug', 'info', 'warn', 'error'] as const).includes(logLevel)) { throw new TypeError('logLevel must be debug, info, warn, or error.') } @@ -2077,6 +2092,60 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions): RequestHan transport.setPeer(peer) const telemetry = new Telemetry(telemetryConfig) + const rawReader = + options.captureRawBody === true + ? new RawRequestBodyReader( + transportLimits?.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES, + transportLimits?.requestTimeoutMs ?? 30_000, + transportLimits?.maxPendingRequests ?? 1_000 + ) + : undefined + const dispatch = async (req: AuthRequest, res: Response, next: NextFunction): Promise => { + try { + if (rawReader !== undefined) { + await rawReader.capture(req, res) + if (req.path === WELL_KNOWN_AUTH_PATH) { + req.body = decodePaymentPayload(req.body, 'application/json') + } + } + const verifiedNext: NextFunction = (error?: unknown): void => { + if (error !== undefined) { + next(error) + return + } + if (rawReader?.hasCaptured(req) === true && req.path !== WELL_KNOWN_AUTH_PATH) { + if (req.auth !== undefined && req.auth.identityKey !== 'unknown') + req.auth.supportsMultipart = true + req.rawBody = req.body + const contentType = req.headers['content-type'] + if (typeof contentType !== 'string' || !isMultipartPaymentType(contentType)) { + try { + req.body = decodePaymentPayload(req.rawBody, contentType) + } catch { + res.status(400).json({ + status: 'error', + code: 'ERR_AUTH_MALFORMED', + description: 'Invalid authenticated application body.' + }) + return + } + } + } + next() + } + await transport.handleIncomingRequest(req, res, verifiedNext, onCertificatesReceived) + } catch (error) { + if (error instanceof RawRequestBodyError && !res.headersSent && !res.destroyed) { + res.setHeader('Connection', 'close') + res.status(error.status).json({ + status: 'error', + code: 'ERR_AUTH_BODY', + description: 'The request body could not be accepted.' + }) + } else throw error + } + } + return (req, res, next) => { if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) { getLogMethod(logger, 'debug')('[createAuthMiddleware] Incoming request to auth middleware', { @@ -2086,7 +2155,7 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions): RequestHan }) } if (!telemetry.enabled) { - void transport.handleIncomingRequest(req, res, next, onCertificatesReceived).catch(next) + void dispatch(req, res, next).catch(next) return } @@ -2133,11 +2202,9 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions): RequestHan end(res.writableEnded ? 'ok' : 'cancelled', undefined, 'connection_closed') }) - void transport - .handleIncomingRequest(req, res, tracedNext, onCertificatesReceived) - .catch(error => { - end('error', error, 'middleware_error') - next(error) - }) + void dispatch(req, res, tracedNext).catch(error => { + end('error', error, 'middleware_error') + next(error) + }) } } diff --git a/packages/middleware/auth-express-middleware/src/rawRequestBody.ts b/packages/middleware/auth-express-middleware/src/rawRequestBody.ts new file mode 100644 index 000000000..e54d964e6 --- /dev/null +++ b/packages/middleware/auth-express-middleware/src/rawRequestBody.ts @@ -0,0 +1,114 @@ +import type { Request, Response } from 'express' + +export class RawRequestBodyError extends Error { + constructor(readonly status: number) { + super('The raw authentication request could not be read.') + } +} + +/** Optional receiver-first raw collector. No multipart extraction occurs here. */ +export class RawRequestBodyReader { + private bufferedBytes = 0 + private pending = 0 + private readonly captured = new WeakSet() + + constructor( + private readonly maxRequestBytes: number, + private readonly timeoutMs: number, + private readonly maxPending: number, + private readonly maxBufferedBytes = 64 * 1024 * 1024 + ) { + if (!Number.isSafeInteger(maxRequestBytes) || maxRequestBytes < 1) { + throw new RangeError('Raw request capture requires a finite positive maxRequestBytes.') + } + } + + hasCaptured(req: Request): boolean { + return this.captured.has(req) + } + + async capture(req: Request, res: Response): Promise { + if (req.body !== undefined || req.readableEnded || req.destroyed) { + throw new RawRequestBodyError(400) + } + if (this.pending >= this.maxPending) throw new RawRequestBodyError(503) + if ( + req.headers['content-encoding'] !== undefined && + req.headers['content-encoding'] !== 'identity' + ) { + throw new RawRequestBodyError(415) + } + const declared = req.headers['content-length'] + if ( + declared !== undefined && + (typeof declared !== 'string' || + !/^\d+$/.test(declared) || + !Number.isSafeInteger(Number(declared))) + ) { + throw new RawRequestBodyError(400) + } + if (declared !== undefined && Number(declared) > this.maxRequestBytes) + throw new RawRequestBodyError(413) + this.pending++ + let length = 0 + let released = false + const release = (): void => { + if (released) return + released = true + this.pending-- + this.bufferedBytes -= length + res.off('finish', release) + res.off('close', release) + } + res.once('finish', release) + res.once('close', release) + try { + await new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let settled = false + const cleanup = (): void => { + clearTimeout(timer) + req.off('data', data) + req.off('end', end) + req.off('error', failed) + req.off('aborted', failed) + res.off('close', failed) + } + const fail = (status: number): void => { + if (settled) return + settled = true + cleanup() + req.pause() + reject(new RawRequestBodyError(status)) + } + const failed = (): void => fail(400) + const data = (chunk: Buffer): void => { + if (!Buffer.isBuffer(chunk)) return fail(400) + if (length + chunk.length > this.maxRequestBytes) return fail(413) + if (this.bufferedBytes + chunk.length > this.maxBufferedBytes) return fail(503) + length += chunk.length + this.bufferedBytes += chunk.length + chunks.push(chunk) + } + const end = (): void => { + if (declared !== undefined && Number(declared) !== length) return fail(400) + settled = true + cleanup() + req.body = length === 0 ? undefined : Buffer.concat(chunks, length) + this.captured.add(req) + resolve() + } + const timer = setTimeout(() => fail(408), this.timeoutMs) + timer.unref?.() + req.on('data', data) + req.once('end', end) + req.once('error', failed) + req.once('aborted', failed) + res.once('close', failed) + }) + } catch (error) { + release() + throw error + } + } +} diff --git a/packages/middleware/payment-express-middleware/README.md b/packages/middleware/payment-express-middleware/README.md index 5f893fefb..93b84037d 100644 --- a/packages/middleware/payment-express-middleware/README.md +++ b/packages/middleware/payment-express-middleware/README.md @@ -9,6 +9,19 @@ This protocol is distinct from the newer BRC-121 implementation in `@bsv/402-pay`. Choose one protocol deliberately; their headers and client contracts are not interchangeable. +## BRC-118 multipart payments + +Set `enableMultipart: true` behind `createAuthMiddleware({ wallet, +captureRawBody: true })`, installed before any body parser. Negotiation advertises +`header,multipart` only when exact-byte authentication is active. Small payments +retain `x-bsv-payment`; multipart payments share the same amount, BRC-29, BEEF, +internalization and replay validation. The route receives only the original +payload/media type, with exact bytes in `req.rawBody`. Defaults bound payment JSON +to 4 MiB and the complete body to 7 MiB; configure `maxPaymentBytes` and +`maxPaymentBodyBytes` to match the proxy and auth collector. See the +[BRC-118 guide](../../../docs/guides/brc118-payments.md) for receiver-first rollout, +parser policy, browser headers and uncertain payment recovery. + ## Requirements - Node.js 22 or newer diff --git a/packages/middleware/payment-express-middleware/package.json b/packages/middleware/payment-express-middleware/package.json index e2c17e99e..501e46e23 100644 --- a/packages/middleware/payment-express-middleware/package.json +++ b/packages/middleware/payment-express-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/payment-express-middleware", - "version": "2.1.7", + "version": "2.2.0", "sideEffects": false, "engines": { "node": ">=22" @@ -33,11 +33,12 @@ "lint": "oxlint mod.ts src --deny-warnings", "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --exports InMemoryPaymentReplayStore,createPaymentMiddleware && node ../../../scripts/check-auth-express-consumers.mjs @bsv/payment-express-middleware", "prepublishOnly": "pnpm build", - "test": "jest --runInBand", + "test": "jest --runInBand && pnpm test:browser", "test:property": "jest --runInBand src/__tests/PaymentReplayStore.property.test.ts", - "test:coverage": "jest --coverage --runInBand", + "test:coverage": "jest --coverage --runInBand && pnpm test:browser", "test:watch": "jest --watch", - "typecheck": "tsc --project tsconfig.typecheck.json" + "typecheck": "tsc --project tsconfig.typecheck.json", + "test:browser": "node test/brc118-browser.mjs" }, "keywords": [ "BSV", @@ -76,14 +77,16 @@ "ts-jest": "^29.4.12", "ts2md": "^0.2.8", "tsdown": "0.22.14", - "typescript": "npm:@typescript/typescript6@6.0.2" + "typescript": "npm:@typescript/typescript6@6.0.2", + "esbuild": "0.28.1", + "puppeteer-core": "^25.4.0" }, "bugs": { "url": "https://github.com/bsv-blockchain/ts-stack/issues" }, "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware#readme", "peerDependencies": { - "@bsv/sdk": "^2.1.6", + "@bsv/sdk": "^2.9.0", "@types/express": ">=4.17.0 <6", "express": ">=4.18.0 <6" }, diff --git a/packages/middleware/payment-express-middleware/src/__tests/Brc118.integration.test.ts b/packages/middleware/payment-express-middleware/src/__tests/Brc118.integration.test.ts new file mode 100644 index 000000000..9bd16ff18 --- /dev/null +++ b/packages/middleware/payment-express-middleware/src/__tests/Brc118.integration.test.ts @@ -0,0 +1,355 @@ +import express from 'express' +import { createServer, request as httpRequest, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { + AuthFetch, + Beef, + PrivateKey, + PublicKey, + ProtoWallet, + Script, + Transaction, + P2PKH, + type WalletInterface, + type CreateActionArgs +} from '@bsv/sdk' +import { createAuthMiddleware } from '@bsv/auth-express-middleware' +import { createPaymentMiddleware } from '../index.js' +import type { PaymentRequest } from '../types.js' + +jest.setTimeout(30_000) +const servers: Server[] = [] +async function listen(server: Server): Promise { + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return `http://127.0.0.1:${(server.address() as AddressInfo).port}` +} +afterEach(async () => { + jest.restoreAllMocks() + await Promise.all( + servers.splice(0).map(async server => { + server.closeAllConnections() + await new Promise((resolve, reject) => + server.close(error => (error == null ? resolve() : reject(error))) + ) + }) + ) +}) + +async function fixture( + options: { + ancestorBytes?: number + enableMultipart?: boolean + raw?: boolean + proxyBodyLimit?: number + fetchHook?: (url: string, init: RequestInit) => RequestInit + } = {} +) { + const serverWallet = new ProtoWallet(new PrivateKey(23)) as unknown as WalletInterface + const clientWallet = new ProtoWallet(new PrivateKey(24)) as unknown as WalletInterface + const accepted = new Set() + serverWallet.internalizeAction = jest.fn(async args => { + const beef = Beef.fromBinaryStrict(args.tx) + const txid = beef.atomicTxid! + const remittance = args.outputs[0].paymentRemittance! + const key = await serverWallet.getPublicKey({ + protocolID: [2, '3241645161d8'], + keyID: `${remittance.derivationPrefix} ${remittance.derivationSuffix}`, + counterparty: remittance.senderIdentityKey, + forSelf: true + }) + // Derive through the actual wallet, independently of the client's prepared output. + expect(beef.findTxid(txid)!.tx!.outputs[0].lockingScript.toHex()).toBe( + new P2PKH().lock(PublicKey.fromString(key.publicKey).toAddress()).toHex() + ) + if (accepted.has(txid)) return { accepted: true as const, isMerge: true } + accepted.add(txid) + return { accepted: true as const, isMerge: false } + }) + clientWallet.abortAction = jest.fn(async () => ({ aborted: true })) + clientWallet.createAction = jest.fn(async (args: CreateActionArgs) => { + if (args.options?.sendWith !== undefined) + return { + sendWithResults: args.options.sendWith.map(txid => ({ txid, status: 'unproven' as const })) + } + expect(args.options?.noSend).toBe(true) + const source = new Transaction() + source.addOutput({ satoshis: 1000, lockingScript: Script.fromASM('OP_TRUE') }) + if ((options.ancestorBytes ?? 0) > 0) + source.addOutput({ + satoshis: 0, + lockingScript: Script.fromASM(`OP_FALSE OP_RETURN ${'01'.repeat(options.ancestorBytes!)}`) + }) + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + tx.addOutput({ + satoshis: args.outputs![0].satoshis, + lockingScript: Script.fromHex(args.outputs![0].lockingScript) + }) + return { txid: tx.id('hex'), tx: tx.toAtomicBEEF() } + }) + const app = express() + if (options.raw === false) app.use(express.json()) + app.use(createAuthMiddleware({ wallet: serverWallet, captureRawBody: options.raw !== false })) + app.use( + createPaymentMiddleware({ + wallet: serverWallet, + enableMultipart: options.enableMultipart !== false, + calculateRequestPrice: () => 10 + }) + ) + const handler = jest.fn((req: PaymentRequest, res: express.Response) => + res.json({ + method: req.method, + url: req.originalUrl, + mediaType: req.headers['content-type'], + raw: req.rawBody === undefined ? null : Buffer.from(req.rawBody).toString('base64'), + parsed: req.body, + paid: req.payment?.accepted + }) + ) + app.use(handler) + const upstream = await listen(createServer(app)) + const bodyLimit = options.proxyBodyLimit ?? 256 * 1024 + const proxy = createServer({ maxHeaderSize: 6 * 1024 }, (req, res) => { + const chunks: Buffer[] = [] + let size = 0 + if (Number(req.headers['content-length'] ?? 0) > bodyLimit) { + res.writeHead(413, { Connection: 'close' }) + res.end() + return + } + req.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > bodyLimit) { + res.writeHead(413, { Connection: 'close' }) + res.end() + req.pause() + return + } + chunks.push(chunk) + }) + req.on('end', () => { + if (res.writableEnded) return + const destination = new URL(req.url!, upstream) + const forward = httpRequest( + destination, + { method: req.method, headers: req.headers }, + upstreamResponse => { + res.writeHead(upstreamResponse.statusCode!, upstreamResponse.headers) + upstreamResponse.pipe(res) + } + ) + forward.on('error', () => { + if (!res.headersSent) res.writeHead(502) + res.end() + }) + forward.end(Buffer.concat(chunks)) + }) + }) + const origin = await listen(proxy) + const fetchClient: typeof fetch = async (url, init = {}) => + fetch(url, options.fetchHook?.(String(url), init) ?? init) + const client = new AuthFetch(clientWallet, undefined, undefined, undefined, {}, fetchClient) + jest.spyOn(client as any, 'logPaymentAttempt').mockImplementation(() => {}) + return { client, clientWallet, serverWallet, handler, origin } +} + +describe('BRC-118 through signed HTTP and a 6 KiB-header proxy', () => { + it.each([ + ['application/json; charset=utf-8', Buffer.from('{ "snow": "雪" }\n')], + ['application/octet-stream', Buffer.from([0, 128, 255, 13, 10, 0])], + ['text/plain', Buffer.alloc(0)], + [ + 'multipart/form-data; boundary=inner', + Buffer.from( + '--inner\r\nContent-Disposition: form-data; name="upload"; filename="file.bin"\r\nContent-Type: application/octet-stream\r\n\r\n\u0000\u00ff\r\n--inner--\r\n' + ) + ] + ])('preserves original %s payload through a large payment', async (contentType, body) => { + const { client, clientWallet, serverWallet, handler, origin } = await fixture({ + ancestorBytes: 12_000 + }) + const response = await client.fetch(`${origin}/paid?q=retained`, { + method: 'POST', + headers: { 'content-type': contentType }, + body, + paymentRetryAttempts: 1 + }) + const result = await response.json() + expect({ status: response.status, result }).toMatchObject({ + status: 200, + result: { paid: true } + }) + expect(result).toMatchObject({ + method: 'POST', + url: '/paid?q=retained', + mediaType: contentType, + raw: body.toString('base64'), + paid: true + }) + expect(handler).toHaveBeenCalledTimes(1) + expect(serverWallet.internalizeAction).toHaveBeenCalledTimes(1) + expect(clientWallet.createAction).toHaveBeenCalledTimes(2) + }) + + it('keeps header transport compatible with a server whose raw multipart path is disabled', async () => { + const { client, origin, handler } = await fixture({ raw: false }) + expect((await client.fetch(`${origin}/paid`, { paymentRetryAttempts: 1 })).status).toBe(200) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('refuses a large payment for a header-only server and releases the reservation', async () => { + const { client, origin, clientWallet, handler, serverWallet } = await fixture({ + enableMultipart: false, + ancestorBytes: 12_000 + }) + await expect( + client.fetch(`${origin}/paid`, { method: 'POST', paymentRetryAttempts: 1 }) + ).rejects.toMatchObject({ code: 'ERR_PAYMENT_TRANSPORT', payment: { aborted: true } }) + expect(clientWallet.createAction).toHaveBeenCalledTimes(1) + expect(handler).not.toHaveBeenCalled() + expect(serverWallet.internalizeAction).not.toHaveBeenCalled() + }) + + it('treats a real unsigned proxy body refusal as terminal without a second spend', async () => { + const { client, origin, clientWallet, serverWallet, handler } = await fixture({ + ancestorBytes: 12_000, + proxyBodyLimit: 4000 + }) + await expect(client.fetch(`${origin}/paid`, { method: 'POST' })).rejects.toMatchObject({ + code: 'ERR_PAYMENT_SIZE', + httpStatus: 413, + authenticated: false, + payment: { state: 'submitted' } + }) + expect(clientWallet.createAction).toHaveBeenCalledTimes(2) + expect(clientWallet.abortAction).not.toHaveBeenCalled() + expect(serverWallet.internalizeAction).not.toHaveBeenCalled() + expect(handler).not.toHaveBeenCalled() + }) + + it('reproduces a real proxy header refusal below the selection threshold without retrying the spend', async () => { + const { client, origin, clientWallet, serverWallet, handler } = await fixture({ + ancestorBytes: 5000 + }) + await expect(client.fetch(`${origin}/paid`, { method: 'POST' })).rejects.toMatchObject({ + code: 'ERR_PAYMENT_SIZE', + httpStatus: 431, + authenticated: false + }) + expect(clientWallet.createAction).toHaveBeenCalledTimes(2) + expect(clientWallet.abortAction).not.toHaveBeenCalled() + expect(serverWallet.internalizeAction).not.toHaveBeenCalled() + expect(handler).not.toHaveBeenCalled() + }) + + it.each(['boundary', 'payment', 'payload', 'signature'])( + 'rejects modified %s before payment or route work', + async change => { + const { client, origin, handler, serverWallet, clientWallet } = await fixture({ + ancestorBytes: 12_000, + fetchHook: (_url, init) => { + const headers = { ...init.headers } as Record + if (!headers['content-type']?.startsWith('multipart/form-data')) return init + if (change === 'boundary') headers['content-type'] += 'changed' + if (change === 'signature') headers['x-bsv-auth-signature'] = '00' + let body = init.body + if (change === 'payment' || change === 'payload') { + const bytes = Buffer.from(body as Uint8Array) + const target = bytes.indexOf(change === 'payment' ? 'transaction' : 'original') + bytes[target] ^= 1 + body = bytes + } + return { ...init, headers, body } + } + }) + await expect( + client.fetch(`${origin}/paid`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'original', + paymentRetryAttempts: 1 + }) + ).rejects.toThrow() + expect(serverWallet.internalizeAction).not.toHaveBeenCalled() + expect(handler).not.toHaveBeenCalled() + expect(clientWallet.createAction).toHaveBeenCalledTimes(2) + } + ) + it('rejects a newly authenticated replay of the same multipart payment', async () => { + let captured: { body: Uint8Array; contentType: string } | undefined + const { client, origin, handler, serverWallet, clientWallet } = await fixture({ + ancestorBytes: 12_000, + fetchHook: (_url, init) => { + const contentType = (init.headers as Record)['content-type'] + if (contentType?.startsWith('multipart/form-data')) + captured = { body: new Uint8Array(init.body as Uint8Array), contentType } + return init + } + }) + expect((await client.fetch(`${origin}/paid`, { method: 'POST' })).status).toBe(200) + expect(captured).toBeDefined() + const replay = await client.fetch(`${origin}/paid`, { + method: 'POST', + body: captured!.body, + headers: { 'content-type': captured!.contentType } + }) + expect(replay.status).toBe(409) + expect(await replay.json()).toMatchObject({ code: 'ERR_PAYMENT_REPLAYED' }) + expect(handler).toHaveBeenCalledTimes(1) + expect(serverWallet.internalizeAction).toHaveBeenCalledTimes(2) + expect(clientWallet.createAction).toHaveBeenCalledTimes(2) + }) + + it.each([false, true])( + 'does not rerun the route or pay again after a wallet failure (accepted before error=%s)', + async acceptedFirst => { + const { client, origin, handler, serverWallet, clientWallet } = await fixture({ + ancestorBytes: 12_000 + }) + const original = serverWallet.internalizeAction + serverWallet.internalizeAction = jest.fn(async args => { + if (acceptedFirst) await original(args) + throw new Error('private wallet provider context') + }) + const response = await client.fetch(`${origin}/paid`, { method: 'POST' }) + expect(response.ok).toBe(false) + expect(response.status).toBe(400) + const body = await response.text() + expect(body).toContain('ERR_PAYMENT_FAILED') + expect(body).not.toContain('private wallet provider context') + expect(handler).not.toHaveBeenCalled() + expect(serverWallet.internalizeAction).toHaveBeenCalledTimes(1) + expect(clientWallet.createAction).toHaveBeenCalledTimes(2) + expect(clientWallet.abortAction).not.toHaveBeenCalled() + } + ) + + it('rejects simultaneous signed header and multipart payment sources before wallet work', async () => { + let captured: { body: Uint8Array; contentType: string } | undefined + const { client, origin, handler, serverWallet, clientWallet } = await fixture({ + ancestorBytes: 12_000, + fetchHook: (_url, init) => { + const contentType = (init.headers as Record)['content-type'] + if (contentType?.startsWith('multipart/form-data')) + captured = { body: new Uint8Array(init.body as Uint8Array), contentType } + return init + } + }) + expect((await client.fetch(`${origin}/paid`, { method: 'POST' })).status).toBe(200) + const response = await client.fetch(`${origin}/paid`, { + method: 'POST', + body: captured!.body, + headers: { 'content-type': captured!.contentType, 'x-bsv-payment': '{}' } + }) + expect(response.status).toBe(400) + expect(handler).toHaveBeenCalledTimes(1) + expect(serverWallet.internalizeAction).toHaveBeenCalledTimes(1) + expect(clientWallet.createAction).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts b/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts new file mode 100644 index 000000000..c8b5bb716 --- /dev/null +++ b/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts @@ -0,0 +1,139 @@ +import { buildMultipartPayment } from '@bsv/sdk/auth/utils/paymentTransport' +import { parseMultipartPayment } from '../multipartPayment.js' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const boundary = '----BsvPayment0011223344556677' +const payment = '{"derivationPrefix":"AQ==","derivationSuffix":"Ag==","transaction":"AQID"}' +const type = `multipart/form-data; boundary=${boundary}` +function part(name: string, body: string | Buffer, contentType = 'application/json'): Buffer { + return Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\nContent-Type: ${contentType}\r\n\r\n` + ), + Buffer.from(body), + Buffer.from('\r\n') + ]) +} +function wire(...parts: Buffer[]): Buffer { + return Buffer.concat([...parts, Buffer.from(`--${boundary}--\r\n`)]) +} +function parse(bytes: Uint8Array, contentType = type) { + return parseMultipartPayment(bytes, contentType, 10_000, 1000) +} + +describe('authenticated BRC-118 extraction', () => { + const vectors = JSON.parse( + readFileSync( + resolve(process.cwd(), '../../../conformance/vectors/payments/brc118.json'), + 'utf8' + ) + ).vectors as Array<{ + id: string + input: { + payment_json: string + payload_base64: string | null + payload_content_type: string | null + } + expected: { body_base64: string; content_type: string } + }> + it.each(vectors)('reads independent Python wire fixture $id', vector => { + const parsed = parse( + Buffer.from(vector.expected.body_base64, 'base64'), + vector.expected.content_type + ) + expect(parsed.paymentJSON).toBe(vector.input.payment_json) + expect(parsed.body === undefined ? null : Buffer.from(parsed.body).toString('base64')).toBe( + vector.input.payload_base64 + ) + expect(parsed.contentType ?? null).toBe(vector.input.payload_content_type) + }) + it.each([ + ['application/json; charset=utf-8', Buffer.from('{ "snow": "雪" }\n')], + ['application/octet-stream', Buffer.from([0, 255, 128, 13, 10, 0])], + ['text/plain', Buffer.from('')], + ['multipart/form-data; boundary=inner', Buffer.from('--inner\r\nunchanged\r\n--inner--')] + ])('preserves bytes and media type for %s', (contentType, body) => { + const built = buildMultipartPayment(payment, { bytes: body, contentType }, 10_000, boundary) + expect(Buffer.from(built.body)).toEqual( + wire(part('x-bsv-payment', payment), part('body', body, contentType)) + ) + const parsed = parse(built.body) + expect(parsed.paymentJSON).toBe(payment) + expect(parsed.contentType).toBe(contentType) + expect(Buffer.from(parsed.body!)).toEqual(body) + expect(parsed.body!.buffer.byteLength).toBe(body.length) + }) + + it('accepts payment-only, quoted boundaries, and either part order', () => { + expect( + parse(wire(part('x-bsv-payment', payment)), `multipart/form-data; boundary="${boundary}"`) + ).toEqual({ paymentJSON: payment, body: undefined, contentType: undefined }) + expect( + Buffer.from( + parse(wire(part('body', 'payload', 'text/plain'), part('x-bsv-payment', payment))).body! + ) + ).toEqual(Buffer.from('payload')) + }) + + it.each([ + wire(), + wire(part('body', 'only')), + wire(part('x-bsv-payment', payment), part('x-bsv-payment', payment)), + wire(part('x-bsv-payment', payment), part('body', ''), part('body', '')), + wire(part('unexpected', payment)), + wire(part('x-bsv-payment', payment, 'text/plain')), + wire(part('x-bsv-payment', payment)).subarray(0, -5), + Buffer.concat([wire(part('x-bsv-payment', payment)), Buffer.from('epilogue')]), + Buffer.from( + wire(part('x-bsv-payment', payment)) + .toString() + .replace('Content-Type:', 'Content-Transfer-Encoding: base64\r\nContent-Type:') + ), + Buffer.from( + wire(part('x-bsv-payment', payment)) + .toString() + .replace('Content-Type:', 'Content-Type: application/json\r\nContent-Type:') + ), + Buffer.from(wire(part('x-bsv-payment', payment)).toString().replaceAll('\r\n', '\n')), + wire(part('x-bsv-payment', Buffer.from([255, 255]))) + ])('rejects malformed or ambiguous framing %#', bytes => { + expect(() => parse(bytes)).toThrow() + }) + + it.each([ + 'multipart/form-data', + `${type}; boundary=second`, + 'multipart/form-data; boundary="a b"', + `${type}\r\nx: y` + ])('rejects invalid Content-Type %j', contentType => { + expect(() => parse(wire(part('x-bsv-payment', payment)), contentType)).toThrow() + }) + + it('bounds body, payment, and part headers', () => { + const bytes = wire(part('x-bsv-payment', payment)) + expect(() => parseMultipartPayment(bytes, type, bytes.length - 1, 1000)).toThrow('limit') + expect(() => parseMultipartPayment(bytes, type, 10_000, payment.length - 1)).toThrow('limit') + expect(() => + parse(wire(part('x-bsv-payment', payment, `application/json;${'x'.repeat(2100)}`))) + ).toThrow() + }) + + it('does not truncate a binary payload at a boundary prefix', () => { + const body = Buffer.from(`abc\r\n--${boundary}not-a-delimiter\r\nend`) + expect( + Buffer.from( + parse(wire(part('x-bsv-payment', payment), part('body', body, 'application/octet-stream'))) + .body! + ) + ).toEqual(body) + expect(() => + buildMultipartPayment( + payment, + { bytes: body, contentType: 'application/octet-stream' }, + 10_000, + boundary + ) + ).toThrow('collides') + }) +}) diff --git a/packages/middleware/payment-express-middleware/src/index.ts b/packages/middleware/payment-express-middleware/src/index.ts index b33858071..fcdb9ecd5 100644 --- a/packages/middleware/payment-express-middleware/src/index.ts +++ b/packages/middleware/payment-express-middleware/src/index.ts @@ -1,3 +1,6 @@ +import { isMultipartPaymentType, PaymentTransportError } from '@bsv/sdk/auth/utils/paymentTransport' +import { decodePaymentPayload } from '@bsv/sdk/auth/utils/decodePaymentPayload' +import { parseMultipartPayment, MissingMultipartPayment } from './multipartPayment.js' import { toArray, toBase64 } from '@bsv/sdk/primitives/utils' import { Beef, createNonce, PublicKey, verifyNonce, type AtomicBEEF } from '@bsv/sdk' import type { RequestHandler, Response } from 'express' @@ -44,17 +47,16 @@ function isPositiveSafeInteger(value: number): boolean { } function isCanonicalBase64(value: string): boolean { - if ( - value.length === 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) - ) { - return false - } - try { - return toBase64(toArray(value, 'base64')) === value - } catch { + if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { return false } + // Check pad bits without decoding or using a repeated-group regex, whose + // engine stack can overflow on the large BEEFs multipart was designed for. + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0 + const last = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.indexOf( + value[value.length - padding - 1] + ) + return padding === 0 || (padding === 1 ? (last & 3) === 0 : (last & 15) === 0) } function isCompressedPublicKey(value: string): boolean { @@ -187,7 +189,8 @@ async function issuePaymentChallenge( wallet: PaymentMiddlewareOptions['wallet'], res: Response, requestPrice: number, - logger: PaymentMiddlewareOptions['logger'] + logger: PaymentMiddlewareOptions['logger'], + multipart = false ): Promise { try { const derivationPrefix = await createNonce(wallet) @@ -196,7 +199,8 @@ async function issuePaymentChallenge( .set({ 'x-bsv-payment-version': PAYMENT_VERSION, 'x-bsv-payment-satoshis-required': String(requestPrice), - 'x-bsv-payment-derivation-prefix': derivationPrefix + 'x-bsv-payment-derivation-prefix': derivationPrefix, + 'x-bsv-payment-transports': multipart ? 'header,multipart' : 'header' }) .json({ status: 'error', @@ -223,6 +227,9 @@ export function createPaymentMiddleware(options: PaymentMiddlewareOptions): Requ wallet, replayStore = new InMemoryPaymentReplayStore(), maxPaymentHeaderBytes = DEFAULT_MAX_PAYMENT_HEADER_BYTES, + enableMultipart = false, + maxPaymentBodyBytes = 7 * 1024 * 1024, + maxPaymentBytes = 4 * 1024 * 1024, logger } = options @@ -245,6 +252,12 @@ export function createPaymentMiddleware(options: PaymentMiddlewareOptions): Requ if (!isPaymentLogger(logger)) { throw new TypeError('logger error and warn properties must be functions when provided.') } + if (typeof enableMultipart !== 'boolean') + throw new TypeError('enableMultipart must be a boolean.') + for (const value of [maxPaymentBodyBytes, maxPaymentBytes]) { + if (!Number.isSafeInteger(value) || value < 1 || value > 16 * 1024 * 1024) + throw new RangeError('Multipart payment limits must be integers from 1 through 16777216.') + } return async (req, res, next): Promise => { const paymentRequest: PaymentRequest = req @@ -259,6 +272,51 @@ export function createPaymentMiddleware(options: PaymentMiddlewareOptions): Requ return } + const multipart = enableMultipart && paymentRequest.auth?.supportsMultipart === true + const contentType = paymentRequest.headers['content-type'] + let rawPayment = paymentHeader(paymentRequest) + let paymentLimit = maxPaymentHeaderBytes + if (enableMultipart && typeof contentType === 'string' && isMultipartPaymentType(contentType)) { + if (!multipart || rawPayment !== undefined || !(paymentRequest.body instanceof Uint8Array)) { + sendError( + res, + 400, + 'ERR_MALFORMED_PAYMENT', + 'Multipart payments require raw authentication and exactly one payment source.' + ) + return + } + try { + const parsed = parseMultipartPayment( + paymentRequest.body, + contentType, + maxPaymentBodyBytes, + maxPaymentBytes + ) + paymentRequest.rawBody = parsed.body + paymentRequest.body = decodePaymentPayload(parsed.body, parsed.contentType) + delete paymentRequest.headers['content-type'] + delete paymentRequest.headers['content-length'] + delete paymentRequest.headers['transfer-encoding'] + if (parsed.contentType !== undefined) + paymentRequest.headers['content-type'] = parsed.contentType + if (parsed.body !== undefined) + paymentRequest.headers['content-length'] = String(parsed.body.length) + rawPayment = parsed.paymentJSON + paymentLimit = maxPaymentBytes + } catch (error) { + if (!(error instanceof MissingMultipartPayment)) { + sendError( + res, + error instanceof PaymentTransportError && error.code === 'ERR_PAYMENT_SIZE' ? 413 : 400, + 'ERR_MALFORMED_PAYMENT', + 'The multipart payment is malformed or exceeds its limit.' + ) + return + } + } + } + let requestPrice: number try { requestPrice = await calculateRequestPrice(paymentRequest) @@ -284,9 +342,8 @@ export function createPaymentMiddleware(options: PaymentMiddlewareOptions): Requ return } - const rawPayment = paymentHeader(paymentRequest) if (rawPayment === undefined) { - await issuePaymentChallenge(wallet, res, requestPrice, logger) + await issuePaymentChallenge(wallet, res, requestPrice, logger, multipart) return } @@ -295,7 +352,7 @@ export function createPaymentMiddleware(options: PaymentMiddlewareOptions): Requ return } - const payment = parsePaymentHeader(rawPayment, maxPaymentHeaderBytes) + const payment = parsePaymentHeader(rawPayment, paymentLimit) if (payment === undefined) { sendError(res, 400, 'ERR_MALFORMED_PAYMENT', 'The X-BSV-Payment header is malformed.') return diff --git a/packages/middleware/payment-express-middleware/src/multipartPayment.ts b/packages/middleware/payment-express-middleware/src/multipartPayment.ts new file mode 100644 index 000000000..283f73f7c --- /dev/null +++ b/packages/middleware/payment-express-middleware/src/multipartPayment.ts @@ -0,0 +1,114 @@ +import { + paymentBoundary, + paymentPayloadContentType, + PaymentTransportError +} from '@bsv/sdk/auth/utils/paymentTransport' +import { toUTF8Strict } from '@bsv/sdk/primitives/utils' + +export interface ParsedMultipartPayment { + paymentJSON: string + body?: Uint8Array + contentType?: string +} + +/** An ordinary, unpaid multipart application request needs the normal 402 challenge. */ +export class MissingMultipartPayment extends Error {} + +/** A bounded two-part RFC 7578 profile. Run only after authenticating the complete raw request. */ +export function parseMultipartPayment( + bytes: Uint8Array, + contentType: string, + maxBodyBytes: number, + maxPaymentBytes: number +): ParsedMultipartPayment { + const malformed = (): never => { + throw new PaymentTransportError('ERR_PAYMENT_TRANSPORT', 'Malformed multipart payment.') + } + if (bytes.length > maxBodyBytes) + throw new PaymentTransportError('ERR_PAYMENT_SIZE', 'Multipart payment body exceeds its limit.') + const boundary = paymentBoundary(contentType) + const source = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const delimiter = Buffer.from(`\r\n--${boundary}`) + const opening = Buffer.from(`--${boundary}\r\n`) + if (source.equals(Buffer.from(`--${boundary}--\r\n`))) throw new MissingMultipartPayment() + if (!source.subarray(0, opening.length).equals(opening)) malformed() + let position = opening.length + let paymentJSON: string | undefined + let body: Uint8Array | undefined + let bodyType: string | undefined + let parts = 0 + let invalidPaymentProfile = false + const seen = new Set() + while (true) { + if (++parts > 128) malformed() + // Bound the header search itself, before reading any attacker-sized field. + const headerWindow = source.subarray(position, Math.min(position + 2048 + 4, source.length)) + const headerLength = headerWindow.indexOf('\r\n\r\n') + if (headerLength < 0 || headerLength > 2048) malformed() + const rawHeaders = headerWindow.subarray(0, headerLength).toString('ascii') + if ( + headerWindow + .subarray(0, headerLength) + .some(byte => byte > 126 || (byte < 32 && byte !== 13 && byte !== 10)) + ) + malformed() + const headers = new Map() + for (const line of rawHeaders.split('\r\n')) { + const match = /^([A-Za-z-]+):[ \t]*(.*)$/.exec(line) + if (match === null) malformed() + const name = match![1].toLowerCase() + if (headers.has(name) || (name !== 'content-disposition' && name !== 'content-type')) + malformed() + headers.set(name, match![2].trim()) + } + const disposition = + /^form-data;\s*name="([^"\r\n]{1,128})"(?:;\s*filename="[^"\r\n]{0,256}")?$/i.exec( + headers.get('content-disposition') ?? '' + ) + if (disposition === null) malformed() + const name = disposition![1] + if (seen.has(name)) invalidPaymentProfile = true + seen.add(name) + const partType = headers.get('content-type') + if (partType !== undefined) paymentPayloadContentType(partType) + const start = position + headerLength + 4 + let end = source.indexOf(delimiter, start) + while (end !== -1) { + const suffix = source + .subarray(end + delimiter.length, end + delimiter.length + 2) + .toString('ascii') + if (suffix === '\r\n' || suffix === '--') break + end = source.indexOf(delimiter, end + delimiter.length) + } + if (end === -1) malformed() + if (name === 'x-bsv-payment') { + if (partType === undefined || !/^application\/json(?:;\s*charset=utf-8)?$/i.test(partType)) + malformed() + if (end - start > maxPaymentBytes) + throw new PaymentTransportError( + 'ERR_PAYMENT_SIZE', + 'Multipart payment JSON exceeds its limit.' + ) + paymentJSON = toUTF8Strict(source.subarray(start, end)) + } else if (name === 'body') { + if (partType === undefined) invalidPaymentProfile = true + // Own the inner bytes: downstream views must not retain or expose the outer payment part. + body = new Uint8Array(source.subarray(start, end)) + bodyType = partType + } else invalidPaymentProfile = true + position = end + delimiter.length + if (source.subarray(position, position + 2).toString('ascii') === '--') { + position += 2 + if ( + source.subarray(position).toString('ascii') !== '' && + source.subarray(position).toString('ascii') !== '\r\n' + ) + malformed() + break + } + position += 2 + } + if (paymentJSON === undefined) throw new MissingMultipartPayment() + if (invalidPaymentProfile || parts > 2) malformed() + return { paymentJSON: paymentJSON!, body, contentType: bodyType } +} diff --git a/packages/middleware/payment-express-middleware/src/types.ts b/packages/middleware/payment-express-middleware/src/types.ts index ba081258a..563e86b1a 100644 --- a/packages/middleware/payment-express-middleware/src/types.ts +++ b/packages/middleware/payment-express-middleware/src/types.ts @@ -11,7 +11,9 @@ export interface PaymentReceipt { export interface PaymentRequest extends Request { auth?: { identityKey?: unknown + supportsMultipart?: boolean } + rawBody?: Uint8Array payment?: PaymentReceipt } @@ -33,6 +35,12 @@ export interface PaymentMiddlewareOptions { wallet: WalletInterface replayStore?: PaymentReplayStore maxPaymentHeaderBytes?: number + /** Advertise multipart only when raw-byte auth support is present on the request. */ + enableMultipart?: boolean + /** Complete multipart request size, default 7 MiB. */ + maxPaymentBodyBytes?: number + /** Serialized payment part size, default 4 MiB. */ + maxPaymentBytes?: number logger?: PaymentLogger } diff --git a/packages/middleware/payment-express-middleware/test/brc118-browser.mjs b/packages/middleware/payment-express-middleware/test/brc118-browser.mjs new file mode 100644 index 000000000..f2c7c5ee1 --- /dev/null +++ b/packages/middleware/payment-express-middleware/test/brc118-browser.mjs @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { access, readFile } from 'node:fs/promises' +import { fileURLToPath, pathToFileURL } from 'node:url' +import express from 'express' +import { build } from 'esbuild' +import puppeteer from 'puppeteer-core' +import { Beef, PrivateKey, ProtoWallet, PublicKey, P2PKH } from '@bsv/sdk' +import { createAuthMiddleware } from '@bsv/auth-express-middleware' +import { createPaymentMiddleware } from '../dist/mod.mjs' + +const candidates = [ + process.env.CHROME_BIN, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', + '/usr/bin/chromium' +].filter(Boolean) +let executablePath +for (const candidate of candidates) { + try { + await access(candidate) + executablePath = candidate + break + } catch { + /* next installed browser */ + } +} +assert.ok( + executablePath, + 'Chrome/Chromium or CHROME_BIN is required; the BRC-118 browser gate cannot be skipped.' +) +const bundleOptions = { + entryPoints: [fileURLToPath(new URL('./brc118-client.mjs', import.meta.url))], + bundle: true, + write: false, + platform: 'browser', + format: 'esm', + target: 'es2022' +} +const bundle = await build(bundleOptions) +let legacyBundle +const servers = [] +async function listen(server) { + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return `http://127.0.0.1:${server.address().port}` +} +async function receiver({ multipart = true, exposeTransport = true } = {}) { + const app = express() + const wallet = new ProtoWallet(new PrivateKey(23)) + let accepted = 0 + let handled = 0 + let preflights = 0 + wallet.internalizeAction = async args => { + const beef = Beef.fromBinaryStrict(args.tx) + const remittance = args.outputs[0].paymentRemittance + const key = await wallet.getPublicKey({ + protocolID: [2, '3241645161d8'], + keyID: `${remittance.derivationPrefix} ${remittance.derivationSuffix}`, + counterparty: remittance.senderIdentityKey, + forSelf: true + }) + const output = beef.findTxid(beef.atomicTxid).tx.outputs[0] + assert.equal( + output.lockingScript.toHex(), + new P2PKH().lock(PublicKey.fromString(key.publicKey).toAddress()).toHex() + ) + assert.equal(output.satoshis, 10) + accepted++ + return { accepted: true, isMerge: false } + } + app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') + res.setHeader( + 'Access-Control-Allow-Headers', + req.headers['access-control-request-headers'] ?? '*' + ) + res.setHeader( + 'Access-Control-Expose-Headers', + exposeTransport + ? '*' + : 'x-bsv-auth-version, x-bsv-auth-identity-key, x-bsv-auth-nonce, x-bsv-auth-your-nonce, x-bsv-auth-signature, x-bsv-auth-request-id, x-bsv-payment-version, x-bsv-payment-satoshis-required, x-bsv-payment-derivation-prefix' + ) + if (req.method === 'OPTIONS') { + preflights++ + res.sendStatus(204) + return + } + next() + }) + app.use(createAuthMiddleware({ wallet, captureRawBody: true })) + app.use( + createPaymentMiddleware({ wallet, enableMultipart: multipart, calculateRequestPrice: () => 10 }) + ) + app.use((req, res) => { + handled++ + res.json({ + raw: Array.from(req.rawBody ?? []), + mediaType: req.headers['content-type'], + paid: req.payment.accepted + }) + }) + return { + origin: await listen(createServer(app)), + counts: () => ({ accepted, handled, preflights }) + } +} +let browser +try { + const pageOrigin = await listen( + createServer((req, res) => { + res.setHeader('Content-Type', req.url?.endsWith('.js') ? 'text/javascript' : 'text/html') + res.end( + req.url === '/legacy.js' + ? legacyBundle?.outputFiles[0].contents + : req.url === '/client.js' + ? bundle.outputFiles[0].contents + : 'BRC-118 cross-origin acceptance' + ) + }) + ) + browser = await puppeteer.launch({ executablePath, headless: true, args: ['--no-sandbox'] }) + const page = await browser.newPage() + page.on('pageerror', error => process.stderr.write(`${error}\n`)) + await page.goto(pageOrigin) + await page.waitForFunction('typeof globalThis.pay === "function"') + const cases = [ + { contentType: 'application/octet-stream', body: [0, 255, 128, 13, 10], ancestorBytes: 12000 }, + { + contentType: 'application/json; charset=utf-8', + body: Array.from(Buffer.from('{ "snow": "雪" }\n')), + ancestorBytes: 12000 + }, + { contentType: 'text/plain', body: [], ancestorBytes: 0, multipart: false } + ] + for (const item of cases) { + const target = await receiver(item) + const result = await page.evaluate(async args => await globalThis.pay(args), { + ...item, + origin: target.origin + }) + assert.equal(result.status, 200, JSON.stringify(result)) + assert.deepEqual(result.result.raw, item.body) + assert.equal(result.result.mediaType, item.contentType) + assert.equal(result.result.paid, true) + assert.deepEqual([result.prepared, result.submitted, result.aborted], [1, 1, 0]) + assert.ok(result.challenges.includes(item.multipart === false ? 'header' : 'header,multipart')) + assert.equal(target.counts().accepted, 1) + assert.equal(target.counts().handled, 1) + assert.ok(target.counts().preflights > 0, 'Browser did not exercise cross-origin preflight') + console.log( + JSON.stringify({ + browserPayment: item.contentType, + ancestorBytes: item.ancestorBytes, + ...target.counts() + }) + ) + } + const hidden = await receiver({ exposeTransport: false }) + const refusal = await page.evaluate(async args => await globalThis.pay(args), { + origin: hidden.origin, + ancestorBytes: 12000, + contentType: 'text/plain', + body: [] + }) + // Negotiation is signed: hiding it invalidates authentication before any wallet spend. + assert.equal(refusal.code, 'ERR_INVALID_SIGNATURE', JSON.stringify(refusal)) + assert.deepEqual([refusal.prepared, refusal.submitted, refusal.aborted], [0, 0, 0]) + assert.equal(hidden.counts().accepted, 0) + assert.equal(hidden.counts().handled, 0) + console.log( + 'BRC-118 native browser: binary, exact JSON, header-only compatibility and hidden-capability refusal passed.' + ) + // Optional compatibility probe is additive: the complete current-client gate above always runs. + const legacyModule = process.env.BRC118_LEGACY_SDK_MODULE + if (legacyModule !== undefined) { + const manifest = JSON.parse( + await readFile(new URL('../../package.json', pathToFileURL(legacyModule)), 'utf8') + ) + assert.equal(manifest.name, '@bsv/sdk') + assert.equal(manifest.version, '2.8.0') + legacyBundle = await build({ ...bundleOptions, alias: { '@bsv/sdk': legacyModule } }) + await page.addScriptTag({ url: `${pageOrigin}/legacy.js`, type: 'module' }) + for (const multipart of [true, false]) { + const target = await receiver({ multipart }) + const item = { + origin: target.origin, + ancestorBytes: 0, + contentType: 'text/plain', + body: [108, 101, 103, 97, 99, 121], + legacy: true + } + const result = await page.evaluate(async args => await globalThis.pay(args), item) + assert.equal(result.status, 200, JSON.stringify(result)) + assert.deepEqual(result.result.raw, item.body) + assert.deepEqual([result.prepared, result.submitted, result.aborted], [0, 1, 0]) + assert.equal(target.counts().accepted, 1) + assert.equal(target.counts().handled, 1) + console.log( + JSON.stringify({ + publishedClient: '2.8.0', + receiverMultipart: multipart, + ...target.counts() + }) + ) + } + } +} finally { + if (browser !== undefined) await browser.close() + await Promise.all( + servers.map(async server => { + server.closeAllConnections() + await new Promise((resolve, reject) => + server.close(error => (error ? reject(error) : resolve())) + ) + }) + ) +} diff --git a/packages/middleware/payment-express-middleware/test/brc118-client.mjs b/packages/middleware/payment-express-middleware/test/brc118-client.mjs new file mode 100644 index 000000000..f1a891a30 --- /dev/null +++ b/packages/middleware/payment-express-middleware/test/brc118-client.mjs @@ -0,0 +1,67 @@ +import { AuthFetch, PrivateKey, ProtoWallet, Script, Transaction } from '@bsv/sdk' + +// This code runs in an actual cross-origin browser without Node globals. +globalThis.pay = async ({ origin, ancestorBytes, contentType, body, legacy = false }) => { + const wallet = new ProtoWallet(new PrivateKey(24)) + let prepared = 0 + let submitted = 0 + let aborted = 0 + wallet.abortAction = async () => { + aborted++ + return { aborted: true } + } + wallet.createAction = async args => { + if (args.options?.sendWith !== undefined) { + submitted++ + return { sendWithResults: args.options.sendWith.map(txid => ({ txid, status: 'unproven' })) } + } + if (legacy) submitted++ + else { + prepared++ + if (args.options?.noSend !== true) + throw new Error('Payment was not prepared before submission') + } + const source = new Transaction() + source.addOutput({ satoshis: 1000, lockingScript: Script.fromASM('OP_TRUE') }) + if (ancestorBytes > 0) + source.addOutput({ + satoshis: 0, + lockingScript: Script.fromASM(`OP_FALSE OP_RETURN ${'01'.repeat(ancestorBytes)}`) + }) + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + tx.addOutput({ + satoshis: args.outputs[0].satoshis, + lockingScript: Script.fromHex(args.outputs[0].lockingScript) + }) + return { txid: tx.id('hex'), tx: tx.toAtomicBEEF() } + } + const challenges = [] + const client = new AuthFetch(wallet, undefined, undefined, undefined, {}, async (url, init) => { + const response = await fetch(url, init) + if (response.status === 402) challenges.push(response.headers.get('x-bsv-payment-transports')) + return response + }) + try { + const response = await client.fetch(`${origin}/paid`, { + method: 'POST', + headers: { 'content-type': contentType }, + body: new Uint8Array(body), + paymentRetryAttempts: 1 + }) + return { + status: response.status, + result: await response.json(), + prepared, + submitted, + aborted, + challenges + } + } catch (error) { + return { code: error.code, prepared, submitted, aborted, challenges } + } +} diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 69173b2a5..4cce82dd7 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -214,6 +214,17 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### 2.9.0 candidate — prepared BRC-118 payments and recipient interoperability + +- Negotiate bounded multipart payments, preserving original payload bytes and exact + signed boundary parameters while retaining non-multipart signature preimages. +- Prepare the real payment and request before broadcast; submit once, reuse the + transaction across retries, abort refused reservations where supported, and + retain typed context for cancellation, size refusal and uncertain outcomes. +- Derive the recipient's own BRC-29 child key on settlement receipt. +- Add independent Python wire/preimage vectors, real HTTP/proxy-limit tests and + adversarial payment lifecycle coverage. See the BRC-118 guide for migration. + ### 2.8.0 candidate — authenticated boundaries and additive secure TOTP APIs - Correct empty authenticated HTTP response preimages to use the BRC-104 `-1` diff --git a/packages/sdk/README.md b/packages/sdk/README.md index e9fcc5453..383d2be9b 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -16,7 +16,7 @@ emits a portable `number[]` settlement artifact so HTTP, WebSocket, Message Box, and JSON transports preserve identical transaction bytes. The same boundary protects overlay lookup queries and JSON BEEF responses. -The unpublished 2.8.0 candidate verifies bodyless authenticated HTTP responses +SDK 2.8.0 verifies bodyless authenticated HTTP responses using the BRC-104 `-1` body-length sentinel. Conforming 204 and empty error responses now verify; non-empty response encoding is unchanged. Servers that sign a zero body length for an empty response must adopt the specified sentinel. @@ -34,6 +34,19 @@ the most recent 1,000 entries. Simplified authenticated HTTP frames, bodies, headers, signatures, request IDs, and certificate-request headers have fixed size/count limits and redirects are rejected. +The next release adds negotiated BRC-118 multipart payments. AuthFetch prepares +with `noSend`, validates the actual final payment/request size, then submits that +same payment with `sendWith`. The wallet must support prepare/submit and +`abortAction`. Oversized header-only requests and multipart GET/HEAD requests +fail before broadcast; uncertain submission or delivery requires reconciliation +instead of another automatic spend. See the [BRC-118 guide](../../docs/guides/brc118-payments.md) +for receiver-first rollout, limits, cancellation, raw-byte payloads and typed +`PaymentTransportError` outcomes. Non-multipart authentication remains compatible. + +BRC-29 receipt derives the recipient's own child key (`forSelf: true`). Independent +sender/recipient wallet tests protect this distinction; the payer's sibling +output is not a valid payment to the recipient. + For signature payloads of at least 64 KiB, `ProtoWallet` uses asynchronous platform SHA-256 when Web Crypto is available, avoiding long synchronous hashing on browser UI threads. Unsupported or failed native hashing falls back @@ -161,8 +174,8 @@ For a more detailed tutorial and advanced examples, check our [Documentation](#d `x-bsv-payment-known-txids` response header on its 402 challenge. The value is a comma-separated list of 64-character hexadecimal transaction IDs the recipient already possesses and has validated. `AuthFetch` passes at most - 256 unique lowercase IDs to the wallet's `createAction` options, including - when payment requirements change and a new transaction is created. This + 256 unique lowercase IDs to the wallet's prepared `createAction` options. + Changed payment requirements stop retries and require reconciliation. This lets compatible wallets omit known ancestors from payment BEEF. Whitespace, duplicates, and malformed entries are ignored; an absent or invalid-only header preserves existing payment behavior. Browser services must expose diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f5d7c4863..8b12d77db 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/sdk", - "version": "2.8.0", + "version": "2.9.0", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index dd19d2360..e44cf962c 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -5,7 +5,6 @@ import { toArray as UtilsToArray, toBase64, toHex as UtilsToHex, - toSafeString, toUTF8Strict } from '../../primitives/utils.js' import Random from '../../primitives/Random.js' @@ -29,6 +28,16 @@ import { VerifiableCertificate } from '../certificates/VerifiableCertificate.js' import { Writer } from '../../primitives/utils.js' import { getVerifiableCertificates } from '../utils/getVerifiableCertificates.js' import { copyAuthByteArray } from '../AuthMessageValidation.js' +import { + authenticatedContentType, + PaymentTransportError, + paymentTransports, + preparePaymentTransport, + resolvePaymentTransportLimits, + type PaymentTransportLimits +} from '../utils/paymentTransport.js' +import { Beef } from '../../transaction/Beef.js' +import { validateWalletResult } from '../../wallet/WalletResultValidation.js' interface SimplifiedFetchRequestOptions { method?: string @@ -37,6 +46,10 @@ interface SimplifiedFetchRequestOptions { retryCounter?: number paymentContext?: PaymentRetryContext paymentRetryAttempts?: number + /** BRC-118 wire limits. Infeasible payments are refused before broadcast. */ + paymentTransport?: PaymentTransportLimits + /** Cancellation before broadcast releases the prepared action when the wallet permits. */ + signal?: AbortSignal /** * Optional wallet action labels applied to BRC-105 payment transactions * created for 402 responses. Use these to find payments later via @@ -72,6 +85,14 @@ interface PaymentRetryContext { attempts: number maxAttempts: number errors: PaymentErrorLogEntry[] + txid?: string + state?: 'prepared' | 'submitted' | 'uncertain' + originalRequest?: { method: string; headers: Record; body?: Uint8Array } + preparedRequest?: { + headers: Record + body?: Uint8Array + transport: 'header' | 'multipart' + } requestSummary: { url: string method: string @@ -224,6 +245,21 @@ export class AuthFetch { * @throws Will throw an error if unsupported headers are used or other validation fails. */ async fetch(url: string, config: SimplifiedFetchRequestOptions = {}): Promise { + if (config.signal?.aborted === true) + throw new PaymentTransportError('ERR_PAYMENT_CANCELLED', 'Paid request cancelled.') + // Retain the caller's original bytes before any network or wallet await. + const headers = { ...config.headers } + const ownedBody = + config.body == null + ? undefined + : new Uint8Array(await this.normalizeBodyToNumberArray(config.body)) + config = { + ...config, + headers, + body: ownedBody, + paymentTransport: { ...config.paymentTransport }, + labels: config.labels?.slice() + } if (typeof config.retryCounter === 'number') { if (config.retryCounter <= 0) { throw new Error('Request failed after maximum number of retries.') @@ -271,6 +307,7 @@ export class AuthFetch { } clearTimeout(responseTimeout) this.pendingRequestNonces.delete(requestNonceAsBase64) + config.signal?.removeEventListener('abort', cancelRequest) } const resolveRequest = (response: Response): void => { cleanup() @@ -280,6 +317,21 @@ export class AuthFetch { cleanup() reject(error) } + const cancelRequest = (): void => + rejectRequest( + new PaymentTransportError( + 'ERR_PAYMENT_CANCELLED', + 'Paid request cancelled.', + config.paymentContext == null + ? undefined + : { txid: config.paymentContext.txid, state: config.paymentContext.state } + ) + ) + config.signal?.addEventListener('abort', cancelRequest, { once: true }) + if (config.signal?.aborted === true) { + cancelRequest() + return + } this.pendingRequestNonces.add(requestNonceAsBase64) listenerId = peerToUse.peer.listenForGeneralMessages( @@ -309,7 +361,10 @@ export class AuthFetch { // A certificate prompt can outlive the request deadline. Never // dispatch a request after its caller has already seen a timeout. if (cleaned) return - await peerToUse.peer.toPeer(writer.toArray(), peerToUse.identityKey) + await peerToUse.peer.toPeer( + writer.toArray(), + config.paymentContext?.serverIdentityKey ?? peerToUse.identityKey + ) } catch (error) { // Late transport/session failures must not start recovery that // replays a request after its response deadline has expired. @@ -575,8 +630,8 @@ export class AuthFetch { if (normalizedKey.startsWith('x-bsv-auth')) { throw new Error('No BSV auth headers allowed here!') } - } else if (normalizedKey.startsWith('content-type')) { - value = value.split(';')[0].trim() + } else if (normalizedKey === 'content-type') { + value = authenticatedContentType(value) } else { throw new Error( 'Unsupported header in the simplified fetch implementation. Only content-type, authorization, and x-bsv-* headers are supported.' @@ -612,8 +667,8 @@ export class AuthFetch { return } const bytes = await this.normalizeBodyToNumberArray(body) - writer.writeVarIntNum(bytes.length) - writer.write(bytes) + writer.writeVarIntNum(bytes.length === 0 ? -1 : bytes.length) + if (bytes.length > 0) writer.write(bytes) } /** @@ -730,6 +785,12 @@ export class AuthFetch { } const knownTxids = parseKnownTxidsHeader(originalResponse.headers.get(KNOWN_TXIDS_HEADER)) + const transports = paymentTransports(originalResponse.headers.get('x-bsv-payment-transports')) + if (transports.size === 0) + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'The server advertised no supported payment transport.' + ) let paymentContext = config.paymentContext if (paymentContext == null) { @@ -739,7 +800,8 @@ export class AuthFetch { satoshisRequired, serverIdentityKey, derivationPrefix, - knownTxids + knownTxids, + transports ) } else { const requirementsChanged = !this.isPaymentContextCompatible( @@ -749,18 +811,10 @@ export class AuthFetch { derivationPrefix ) if (requirementsChanged) { - this.logPaymentAttempt( - 'warn', - 'Server adjusted payment requirements; regenerating transaction', - this.composePaymentLogDetails(url, paymentContext) - ) - paymentContext = await this.createPaymentContext( - url, - config, - satoshisRequired, - serverIdentityKey, - derivationPrefix, - knownTxids + throw new PaymentTransportError( + 'ERR_PAYMENT_REQUIREMENTS_CHANGED', + 'The server changed payment requirements after a payment was prepared. Reconcile the existing payment before authorizing another.', + { txid: paymentContext.txid, state: paymentContext.state } ) } } @@ -773,18 +827,87 @@ export class AuthFetch { ) } - const headersWithPayment: Record = { - ...config.headers + // Contexts returned by the released API may already represent a spend. + // Preserve them without another wallet mutation, but validate their real wire bytes. + if (paymentContext.preparedRequest === undefined) { + const beef = Beef.fromBinaryStrict(UtilsToArray(paymentContext.transactionBase64, 'base64')) + if (beef.atomicTxid == null) + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Existing payment context requires Atomic BEEF.' + ) + paymentContext.txid = beef.atomicTxid + paymentContext.state = 'uncertain' + paymentContext.originalRequest = { + method: config.method ?? 'GET', + headers: { ...config.headers }, + body: + config.body == null + ? undefined + : new Uint8Array(await this.normalizeBodyToNumberArray(config.body)) + } + paymentContext.preparedRequest = preparePaymentTransport( + JSON.stringify({ + derivationPrefix: paymentContext.derivationPrefix, + derivationSuffix: paymentContext.derivationSuffix, + transaction: paymentContext.transactionBase64 + }), + paymentContext.originalRequest, + transports, + resolvePaymentTransportLimits(config.paymentTransport) + ) + } + if (!transports.has(paymentContext.preparedRequest.transport)) { + try { + paymentContext.preparedRequest = preparePaymentTransport( + JSON.stringify({ + derivationPrefix: paymentContext.derivationPrefix, + derivationSuffix: paymentContext.derivationSuffix, + transaction: paymentContext.transactionBase64 + }), + paymentContext.originalRequest, + transports, + resolvePaymentTransportLimits(config.paymentTransport) + ) + } catch { + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'The authenticated server no longer supports a deliverable transport for this existing payment.', + { txid: paymentContext.txid, state: paymentContext.state } + ) + } + } + + if (config.signal?.aborted === true) { + let aborted: boolean | undefined + if (paymentContext.state === 'prepared') { + try { + aborted = + (await this.wallet.abortAction({ reference: paymentContext.txid }, this.originator)) + .aborted === true + } catch { + aborted = false + } + } + throw new PaymentTransportError('ERR_PAYMENT_CANCELLED', 'Paid request cancelled.', { + txid: paymentContext.txid, + state: paymentContext.state, + aborted + }) + } + if (paymentContext.state === 'prepared') await this.submitPreparedPayment(paymentContext) + if (config.signal?.aborted === true) { + throw new PaymentTransportError( + 'ERR_PAYMENT_CANCELLED', + 'Paid request cancelled after submission; reconcile its outcome.', + { txid: paymentContext.txid, state: paymentContext.state } + ) } - headersWithPayment['x-bsv-payment'] = JSON.stringify({ - derivationPrefix: paymentContext.derivationPrefix, - derivationSuffix: paymentContext.derivationSuffix, - transaction: paymentContext.transactionBase64 - }) const nextConfig: SimplifiedFetchRequestOptions = { ...config, - headers: headersWithPayment, + headers: paymentContext.preparedRequest.headers, + body: paymentContext.preparedRequest.body, paymentContext } @@ -804,13 +927,33 @@ export class AuthFetch { try { const response = await this.fetch(url, nextConfig) + if (response.status === 413 || response.status === 431) { + throw new PaymentTransportError( + 'ERR_PAYMENT_SIZE', + 'The authenticated server rejected the paid request size.', + { txid: paymentContext.txid, state: paymentContext.state }, + response.status, + true + ) + } this.logPaymentAttempt( - 'info', - `Paid request attempt ${attemptNumber} succeeded`, + response.ok ? 'info' : 'warn', + `Paid request attempt ${attemptNumber} completed with HTTP ${response.status}`, attemptDetails ) return response } catch (error) { + if (error instanceof PaymentTransportError) throw error + const status = error instanceof Error ? (error as any).details?.status : undefined + if (status === 413 || status === 431) { + throw new PaymentTransportError( + 'ERR_PAYMENT_SIZE', + 'An unauthenticated intermediary rejected the paid request size. Reconcile the submitted payment.', + { txid: paymentContext.txid, state: paymentContext.state }, + status, + false + ) + } const errorEntry = this.createPaymentErrorEntry(paymentContext.attempts, error) paymentContext.errors.push(errorEntry) this.logPaymentAttempt('error', `Paid request attempt ${attemptNumber} failed`, { @@ -850,11 +993,46 @@ export class AuthFetch { satoshisRequired: number, serverIdentityKey: string, derivationPrefix: string, - knownTxids?: string[] + knownTxids: string[] | undefined, + transports: ReadonlySet = new Set(['header']) ): Promise { const requestSummary = this.buildPaymentRequestSummary(url, config) const paymentLabels = Array.isArray(config.labels) ? [...config.labels] : [] const maxAttempts = this.getMaxPaymentAttempts(config) + const limits = resolvePaymentTransportLimits(config.paymentTransport) + if (typeof this.wallet.abortAction !== 'function') + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Payment preparation requires a BRC-100 wallet with noSend, sendWith, and abortAction support.' + ) + if (Object.keys(config.headers ?? {}).some(name => name.toLowerCase() === 'x-bsv-payment')) { + throw new PaymentTransportError( + 'ERR_PAYMENT_REQUIREMENTS_CHANGED', + 'An existing payment was rejected. Reconcile it before authorizing another payment.' + ) + } + const originalRequest = { + method: config.method ?? 'GET', + headers: { ...config.headers }, + body: + config.body == null + ? undefined + : new Uint8Array(await this.normalizeBodyToNumberArray(config.body)) + } + if ( + !transports.has('header') && + ['GET', 'HEAD'].includes(originalRequest.method.toUpperCase()) + ) { + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'GET and HEAD require header payment support.' + ) + } + const { publicKey: clientIdentityKey } = validateWalletResult( + 'getPublicKey', + await this.wallet.getPublicKey({ identityKey: true }, this.originator), + { identityKey: true } + ) const derivationSuffix = await createNonce(this.wallet, undefined, this.originator) const { publicKey: derivedPublicKey } = await this.wallet.getPublicKey( @@ -869,58 +1047,153 @@ export class AuthFetch { .lock(PublicKey.fromString(derivedPublicKey).toAddress()) .toHex() - const { tx: transactionResult } = await this.wallet.createAction( - { - description: `Payment for request to ${new URL(url).origin}`, - labels: this.buildPaymentActionLabels( - { labels: paymentLabels }, - derivationPrefix, - derivationSuffix - ), - outputs: [ - { - satoshis: satoshisRequired, - lockingScript, - customInstructions: JSON.stringify({ - derivationPrefix, - derivationSuffix, - payee: serverIdentityKey - }), - outputDescription: 'HTTP request payment' - } - ], - options: { - randomizeOutputs: false, - // Ancestors the recipient already holds are emitted txid-only rather than in full. - // Undefined when the server did not declare any, which is the pre-existing behaviour. - ...(knownTxids != null ? { knownTxids } : {}) + const createArgs = { + description: 'BRC-105 HTTP request payment', + labels: this.buildPaymentActionLabels( + { labels: paymentLabels }, + derivationPrefix, + derivationSuffix + ), + outputs: [ + { + satoshis: satoshisRequired, + lockingScript, + customInstructions: JSON.stringify({ + derivationPrefix, + derivationSuffix, + payee: serverIdentityKey + }), + outputDescription: 'HTTP request payment' } - }, - this.originator - ) - - const transaction = copyAuthByteArray( - transactionResult, - 'BRC-105 payment transaction', - MAX_PAYMENT_TRANSACTION_BYTES - ) - - const { publicKey: clientIdentityKey } = await this.wallet.getPublicKey( - { identityKey: true }, - this.originator - ) + ], + options: { + randomizeOutputs: false, + noSend: true, + acceptDelayedBroadcast: false, + // Ancestors the recipient already holds are emitted txid-only rather than in full. + // Undefined when the server did not declare any, which is the pre-existing behaviour. + ...(knownTxids != null ? { knownTxids } : {}) + } + } + if (config.signal?.aborted === true) + throw new PaymentTransportError( + 'ERR_PAYMENT_CANCELLED', + 'Paid request cancelled before preparation.' + ) + const rawCreated = await this.wallet.createAction(createArgs, this.originator) + let txid: string | undefined + let reference: string | undefined + try { + // A malformed result can still identify a prepared reservation. Read only + // a canonical own data field, never an accessor supplied by an adapter. + const reported = + rawCreated == null ? undefined : Object.getOwnPropertyDescriptor(rawCreated, 'txid')?.value + if (typeof reported === 'string' && /^[0-9a-f]{64}$/.test(reported)) txid = reported + const created = validateWalletResult('createAction', rawCreated, createArgs) + reference = created.signableTransaction?.reference + + const transaction = copyAuthByteArray( + created.tx, + 'BRC-105 payment transaction', + MAX_PAYMENT_TRANSACTION_BYTES + ) + const beef = Beef.fromBinaryStrict(transaction) + const atomicTxid = beef.atomicTxid + const output = atomicTxid == null ? undefined : beef.findTxid(atomicTxid)?.tx?.outputs[0] + if ( + atomicTxid == null || + (txid != null && txid !== atomicTxid) || + output?.satoshis !== satoshisRequired || + output.lockingScript.toHex() !== lockingScript + ) { + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'The prepared payment does not match its authorized recipient and amount.' + ) + } + txid = atomicTxid + const transactionBase64 = toBase64(transaction) + const preparedRequest = preparePaymentTransport( + JSON.stringify({ derivationPrefix, derivationSuffix, transaction: transactionBase64 }), + originalRequest, + transports, + limits + ) + // Exercise the actual released wire decoder's caps before the irreversible sendWith call. + const frame = await this.serializeRequest( + originalRequest.method, + preparedRequest.headers, + preparedRequest.body, + new URL(url), + Random(32) + ) + this.#createTransport(new URL(url).origin).deserializeRequestPayload(frame.toArray()) + if (config.signal?.aborted === true) + throw new PaymentTransportError( + 'ERR_PAYMENT_CANCELLED', + 'Paid request cancelled during preparation.' + ) + return { + satoshisRequired, + transactionBase64, + derivationPrefix, + derivationSuffix, + serverIdentityKey, + clientIdentityKey, + attempts: 0, + maxAttempts, + errors: [], + requestSummary, + txid, + state: 'prepared', + originalRequest, + preparedRequest + } + } catch (error) { + let aborted = false + reference = txid ?? reference + if (reference != null) { + try { + aborted = (await this.wallet.abortAction({ reference }, this.originator)).aborted === true + } catch { + /* retain recovery context */ + } + } + throw new PaymentTransportError( + error instanceof PaymentTransportError ? error.code : 'ERR_PAYMENT_TRANSPORT', + 'Payment preparation could not produce a deliverable request; no broadcast was requested.', + txid == null ? undefined : { txid, state: 'prepared', aborted } + ) + } + } - return { - satoshisRequired, - transactionBase64: toBase64(transaction), - derivationPrefix, - derivationSuffix, - serverIdentityKey, - clientIdentityKey, - attempts: 0, - maxAttempts, - errors: [], - requestSummary + private async submitPreparedPayment(context: PaymentRetryContext): Promise { + // Once submission starts its result may be uncertain. Never abort or create a replacement automatically. + context.state = 'uncertain' + try { + const args = { + description: 'Submit prepared HTTP payment', + options: { sendWith: [context.txid], acceptDelayedBroadcast: false } + } + const result = validateWalletResult( + 'createAction', + await this.wallet.createAction(args, this.originator), + args + ) + if ( + result.sendWithResults?.length !== 1 || + result.sendWithResults[0].txid !== context.txid || + result.sendWithResults[0].status !== 'unproven' + ) { + throw new Error('Payment broadcast did not return affirmative acceptance.') + } + context.state = 'submitted' + } catch { + throw new PaymentTransportError( + 'ERR_PAYMENT_OUTCOME_UNKNOWN', + 'Payment submission was not confirmed. Reconcile this transaction before retrying.', + { txid: context.txid, state: context.state } + ) } } @@ -1101,21 +1374,14 @@ export class AuthFetch { } private createPaymentErrorEntry(attempt: number, error: unknown): PaymentErrorLogEntry { - const entry: PaymentErrorLogEntry = { + // Provider exceptions can echo URLs, request headers or payment bytes. Keep + // only a bounded category in telemetry and terminal diagnostic history. + return { attempt, timestamp: new Date().toISOString(), - message: '', + message: error instanceof Error ? 'Payment delivery failed.' : 'Payment delivery rejected.', stack: undefined } - - if (error instanceof Error) { - entry.message = error.message - entry.stack = error.stack ?? undefined - } else { - entry.message = toSafeString(error) - } - - return entry } private getPaymentRetryDelay(attempt: number): number { @@ -1138,7 +1404,11 @@ export class AuthFetch { ): Error { const safeUrl = this.#safeLogUrl(url) const message = `Paid request to ${safeUrl} failed after ${context.attempts}/${context.maxAttempts} attempts. Sent ${context.satoshisRequired} satoshis to ${context.serverIdentityKey}.` - const error = new Error(message) + const error = new PaymentTransportError( + 'ERR_PAYMENT_OUTCOME_UNKNOWN', + message, + context.txid == null ? undefined : { txid: context.txid, state: context.state ?? 'uncertain' } + ) const failureDetails = { request: context.requestSummary, @@ -1197,28 +1467,32 @@ export class AuthFetch { } // 4. Blob - if (body instanceof Blob) { + if (typeof Blob !== 'undefined' && body instanceof Blob) { const arrayBuffer = await body.arrayBuffer() return Array.from(new Uint8Array(arrayBuffer)) } // 5. FormData - if (body instanceof FormData) { + if (typeof FormData !== 'undefined' && body instanceof FormData) { const entries: [string, string][] = [] body.forEach((value, key) => { - entries.push([key, typeof value === 'string' ? value : value.name]) + if (typeof value !== 'string') + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Serialize file-bearing FormData to owned bytes with its Content-Type before authenticated fetch.' + ) + entries.push([key, value]) }) - const urlEncoded = new URLSearchParams(entries).toString() - return UtilsToArray(urlEncoded, 'utf8') + return UtilsToArray(new URLSearchParams(entries).toString(), 'utf8') } // 6. URLSearchParams - if (body instanceof URLSearchParams) { + if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) { return UtilsToArray(body.toString(), 'utf8') } // 7. ReadableStream - if (body instanceof ReadableStream) { + if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) { throw new TypeError('ReadableStream cannot be directly converted to number[].') } diff --git a/packages/sdk/src/auth/clients/__tests/paymentFixtures.ts b/packages/sdk/src/auth/clients/__tests/paymentFixtures.ts new file mode 100644 index 000000000..09e79a1c7 --- /dev/null +++ b/packages/sdk/src/auth/clients/__tests/paymentFixtures.ts @@ -0,0 +1,29 @@ +import Transaction from '../../../transaction/Transaction.js' +import Script from '../../../script/Script.js' +import { toBase64 } from '../../../primitives/utils.js' +import type { CreateActionArgs, CreateActionResult } from '../../../wallet/Wallet.interfaces.js' + +/** Real Atomic BEEF keeps retry tests sensitive to the wire validation boundary. */ +export async function paymentActionResult(args: CreateActionArgs): Promise { + if (args.options?.sendWith !== undefined) + return { + sendWithResults: args.options.sendWith.map(txid => ({ txid, status: 'unproven' as const })) + } + const source = new Transaction() + source.addOutput({ satoshis: 1000, lockingScript: Script.fromASM('OP_TRUE') }) + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + tx.addOutput({ + satoshis: args.outputs![0].satoshis, + lockingScript: Script.fromHex(args.outputs![0].lockingScript) + }) + return { txid: tx.id('hex'), tx: tx.toAtomicBEEF() } +} + +const legacy = new Transaction() +legacy.addOutput({ satoshis: 5, lockingScript: Script.fromASM('OP_TRUE') }) +export const legacyPaymentBase64 = toBase64(legacy.toAtomicBEEF()) diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.additional.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.additional.test.ts index 34230b95a..98ece1835 100644 --- a/packages/sdk/src/auth/clients/__tests__/AuthFetch.additional.test.ts +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.additional.test.ts @@ -365,11 +365,11 @@ describe('AuthFetch.handlePaymentAndRetry – header validation', () => { }) // --------------------------------------------------------------------------- -// 5. handlePaymentAndRetry – incompatible context triggers new context creation +// 5. handlePaymentAndRetry – incompatible context requires reconciliation // --------------------------------------------------------------------------- describe('AuthFetch.handlePaymentAndRetry – context compatibility', () => { - it('regenerates context when server changes payment requirements', async () => { + it('requires reconciliation when server changes payment requirements', async () => { const authFetch = new AuthFetch(buildWallet()) jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {}) jest.spyOn(authFetch as any, 'wait').mockResolvedValue(undefined) @@ -400,15 +400,15 @@ describe('AuthFetch.handlePaymentAndRetry – context compatibility', () => { .mockResolvedValue(new Response('ok', { status: 200 })) const response = make402Response({ 'x-bsv-payment-satoshis-required': '10' }) // changed from 5 - await (authFetch as any).handlePaymentAndRetry( - 'https://example.com', - { paymentContext: existingContext }, - response - ) - - // createNonce should have been called because the context was regenerated - expect(createNonceMock).toHaveBeenCalled() - expect(fetchSpy).toHaveBeenCalledTimes(1) + await expect( + (authFetch as any).handlePaymentAndRetry( + 'https://example.com', + { paymentContext: existingContext }, + response + ) + ).rejects.toMatchObject({ code: 'ERR_PAYMENT_REQUIREMENTS_CHANGED' }) + expect(createNonceMock).not.toHaveBeenCalled() + expect(fetchSpy).not.toHaveBeenCalled() }) }) @@ -742,11 +742,12 @@ describe('AuthFetch.normalizeBodyToNumberArray (private)', () => { expect(result).toEqual(Utils.toArray('name=alice', 'utf8')) }) - it('normalizes FormData file entries by filename', async () => { + it('rejects FormData files instead of silently replacing their bytes with filenames', async () => { const fd = new FormData() fd.append('upload', new Blob(['hello']), 'greeting.txt') - const result = await (authFetch as any).normalizeBodyToNumberArray(fd) - expect(result).toEqual(Utils.toArray('upload=greeting.txt', 'utf8')) + await expect((authFetch as any).normalizeBodyToNumberArray(fd)).rejects.toThrow( + 'Serialize file-bearing FormData' + ) }) it('normalizes URLSearchParams bytes', async () => { @@ -1128,24 +1129,26 @@ describe('AuthFetch.createPaymentErrorEntry (private)', () => { authFetch = new AuthFetch(buildWallet()) }) - it('extracts message and stack from an Error instance', () => { - const err = new Error('something went wrong') + it('redacts provider messages and stack traces', () => { + const err = new Error('https://user:secret@example.com/private?beef=private-transaction') const entry = (authFetch as any).createPaymentErrorEntry(2, err) expect(entry.attempt).toBe(2) - expect(entry.message).toBe('something went wrong') - expect(typeof entry.stack).toBe('string') + expect(entry.message).toBe('Payment delivery failed.') + expect(JSON.stringify(entry)).not.toContain('secret') + expect(JSON.stringify(entry)).not.toContain('private-transaction') + expect(entry.stack).toBeUndefined() expect(typeof entry.timestamp).toBe('string') }) - it('converts non-Error to string message', () => { + it('redacts non-Error rejection values', () => { const entry = (authFetch as any).createPaymentErrorEntry(1, 'just a string error') - expect(entry.message).toBe('just a string error') + expect(entry.message).toBe('Payment delivery rejected.') expect(entry.stack).toBeUndefined() }) - it('converts numeric error to string message', () => { + it('categorizes numeric rejection values', () => { const entry = (authFetch as any).createPaymentErrorEntry(1, 42) - expect(entry.message).toBe('42') + expect(entry.message).toBe('Payment delivery rejected.') }) }) diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.boundary.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.boundary.test.ts index 49be655c6..23664f9ff 100644 --- a/packages/sdk/src/auth/clients/__tests__/AuthFetch.boundary.test.ts +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.boundary.test.ts @@ -284,6 +284,77 @@ describe('AuthFetch pending-request boundary', () => { expect((authFetch as any).pendingRequestNonces.size).toBe(0) }) + test.each([ + ['serialization', false], + ['serialization', true], + ['certificate wait', false], + ['certificate wait', true] + ] as const)( + 'cancellation during %s drains state and preserves existing payment context (%s)', + async (phase, paid) => { + const controller = new AbortController() + const remove = jest.spyOn(controller.signal, 'removeEventListener') + const peer = { + listenForGeneralMessages: jest.fn(() => 52), + stopListeningForGeneralMessages: jest.fn(), + toPeer: jest.fn() + } + const authFetch = new AuthFetch({} as never) + ;(authFetch as any).peers['https://service.example'] = { + peer, + identityKey: 'server-identity-key', + supportsMutualAuth: true, + pendingCertificateRequests: phase === 'certificate wait' ? [true] : [] + } + let entered!: () => void + let release!: () => void + const started = new Promise(resolve => { + entered = resolve + }) + const gate = new Promise(resolve => { + release = resolve + }) + if (phase === 'serialization') { + const serialize = (authFetch as any).serializeRequest.bind(authFetch) + jest + .spyOn(authFetch as any, 'serializeRequest') + .mockImplementation(async (...args: unknown[]) => { + const result = await serialize(...args) + entered() + await gate + return result + }) + } else { + jest + .spyOn(authFetch as any, 'waitForPendingCertificateRequests') + .mockImplementation(async () => { + entered() + await gate + }) + } + const payment = paid ? { txid: 'ab'.repeat(32), state: 'submitted' } : undefined + const request = authFetch.fetch('https://service.example/resource', { + signal: controller.signal, + paymentContext: payment as any + }) + const rejected = expect(request).rejects.toMatchObject({ + code: 'ERR_PAYMENT_CANCELLED', + message: 'Paid request cancelled.', + payment + }) + await started + controller.abort() + release() + await rejected + expect(peer.toPeer).not.toHaveBeenCalled() + expect(peer.stopListeningForGeneralMessages).toHaveBeenCalledTimes( + phase === 'serialization' ? 0 : 1 + ) + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + expect((authFetch as any).pendingRequestNonces.size).toBe(0) + } + ) + test('times out and cleans an authenticated request with no response', async () => { jest.useFakeTimers() try { @@ -502,7 +573,8 @@ describe('AuthFetch pending-request boundary', () => { recursiveResponse ) expect(fetchSpy).toHaveBeenCalledTimes(2) - expect(config.retryCounter).toBe(3) + expect(fetchSpy.mock.calls[1][1]?.retryCounter).toBe(3) + expect(config.retryCounter).toBeUndefined() expect(authFetch.peers['https://service.example']).toBeUndefined() expect(stopListeningForGeneralMessages).toHaveBeenCalledWith(46) expect((authFetch as any).pendingRequestNonces.size).toBe(0) diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts index 733c0628c..1e4c54ac6 100644 --- a/packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts @@ -3,6 +3,7 @@ import { parseKnownTxidsHeader, AuthFetch } from '../AuthFetch.js' import { Utils, PrivateKey } from '../../../primitives/index.js' import type { CreateActionOptions, WalletInterface } from '../../../wallet/Wallet.interfaces.js' import type { Peer } from '../../Peer.js' +import { paymentActionResult } from '../__tests/paymentFixtures.js' jest.mock('../../utils/createNonce.js', () => ({ createNonce: jest.fn() @@ -13,7 +14,9 @@ import { createNonce } from '../../utils/createNonce.js' const createNonceMock = createNonce as jest.MockedFunction type FetchOptions = NonNullable[1]> type PaymentContext = NonNullable -type TestWallet = jest.Mocked> +type TestWallet = jest.Mocked< + Pick +> interface PaymentInternals { handlePaymentAndRetry: ( @@ -99,9 +102,8 @@ function buildWallet(): TestWallet { .mockImplementation(async opts => opts?.identityKey === true ? { publicKey: identityKey } : { publicKey: derivedKey } ), - createAction: jest.fn().mockResolvedValue({ - tx: Utils.toArray('mock-tx', 'utf8') - }), + createAction: jest.fn(paymentActionResult), + abortAction: jest.fn().mockResolvedValue({ aborted: true }), createHmac: jest.fn().mockResolvedValue({ hmac: Array.from({ length: 32 }, () => 0) }) @@ -156,7 +158,9 @@ describe('AuthFetch.handlePaymentAndRetry – known-txids wiring', () => { } function optionsOfLastCreateAction(wallet: TestWallet): CreateActionOptions { - const options = wallet.createAction.mock.calls.at(-1)?.[0].options + const options = wallet.createAction.mock.calls.find( + ([args]) => args.options?.noSend === true + )?.[0].options if (options === undefined) throw new Error('Expected createAction options') return options } @@ -198,8 +202,12 @@ describe('AuthFetch.handlePaymentAndRetry – known-txids wiring', () => { ) expect(response?.status).toBe(200) - expect(wallet.createAction).toHaveBeenCalledTimes(1) - expect(optionsOfLastCreateAction(wallet)).toEqual({ randomizeOutputs: false }) + expect(wallet.createAction).toHaveBeenCalledTimes(2) + expect(optionsOfLastCreateAction(wallet)).toEqual({ + randomizeOutputs: false, + noSend: true, + acceptDelayedBroadcast: false + }) }) it.each([ @@ -227,7 +235,7 @@ describe('AuthFetch.handlePaymentAndRetry – known-txids wiring', () => { ) expect(response?.status).toBe(200) - expect(wallet.createAction).toHaveBeenCalledTimes(1) + expect(wallet.createAction).toHaveBeenCalledTimes(2) expect(optionsOfLastCreateAction(wallet).knownTxids).toEqual([A]) expect(createNonceMock).toHaveBeenCalledTimes(1) expect(fetchSpy).toHaveBeenCalledTimes(2) @@ -287,9 +295,11 @@ describe('AuthFetch.handlePaymentAndRetry – known-txids wiring', () => { const response = await authFetch.fetch('https://example.com/resource') expect(response.status).toBe(200) - expect(wallet.createAction).toHaveBeenCalledTimes(1) + expect(wallet.createAction).toHaveBeenCalledTimes(2) expect(optionsOfLastCreateAction(wallet)).toEqual({ randomizeOutputs: false, + noSend: true, + acceptDelayedBroadcast: false, knownTxids: [A, B] }) expect(wallet.getPublicKey).toHaveBeenCalledWith( @@ -300,22 +310,15 @@ describe('AuthFetch.handlePaymentAndRetry – known-txids wiring', () => { expect(peer.stopListeningForGeneralMessages).toHaveBeenCalledTimes(2) }) - it('forwards the declared txids when the server changes its price mid-flight', async () => { - // The regeneration branch builds a SECOND transaction. It is the path that matters most: - // a repriced retry is already the largest request in the exchange, so dropping the - // optimisation here would re-ship full ancestry at exactly the wrong moment. + it('requires reconciliation when the server changes price instead of creating another payment', async () => { const { internals, wallet } = harness() - - await internals.handlePaymentAndRetry( - 'https://example.com', - { paymentContext: existingContext(5) }, // server now asks for 10 - make402Response({ - 'x-bsv-payment-satoshis-required': '10', - 'x-bsv-payment-known-txids': A - }) - ) - - expect(wallet.createAction).toHaveBeenCalledTimes(1) - expect(optionsOfLastCreateAction(wallet).knownTxids).toEqual([A]) + await expect( + internals.handlePaymentAndRetry( + 'https://example.com', + { paymentContext: existingContext(5) }, + make402Response({ 'x-bsv-payment-satoshis-required': '10', 'x-bsv-payment-known-txids': A }) + ) + ).rejects.toMatchObject({ code: 'ERR_PAYMENT_REQUIREMENTS_CHANGED' }) + expect(wallet.createAction).not.toHaveBeenCalled() }) }) diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.multipart.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.multipart.test.ts new file mode 100644 index 000000000..f383e8401 --- /dev/null +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.multipart.test.ts @@ -0,0 +1,253 @@ +import { AuthFetch } from '../AuthFetch.js' +import PrivateKey from '../../../primitives/PrivateKey.js' +import Transaction from '../../../transaction/Transaction.js' +import Script from '../../../script/Script.js' +import { ProtoWallet } from '../../../wallet/ProtoWallet.js' +import { toBase64 } from '../../../primitives/utils.js' +import type { WalletInterface, CreateActionArgs } from '../../../wallet/Wallet.interfaces.js' + +const key = new PrivateKey(17) +const identity = key.toPublicKey().toString() +const prefix = toBase64(Array.from({ length: 48 }, () => 1)) +function setup(ancestorBytes = 0) { + const wallet = new ProtoWallet(key) as unknown as WalletInterface + const prepared: string[] = [] + wallet.abortAction = jest.fn(async () => ({ aborted: true })) + wallet.createAction = jest.fn(async (args: CreateActionArgs) => { + if (args.options?.sendWith != null) + return { + sendWithResults: args.options.sendWith.map(txid => ({ txid, status: 'unproven' as const })) + } + const source = new Transaction() + source.addOutput({ satoshis: 1000, lockingScript: Script.fromASM('OP_TRUE') }) + if (ancestorBytes > 0) + source.addOutput({ + satoshis: 0, + lockingScript: Script.fromASM(`OP_FALSE OP_RETURN ${'01'.repeat(ancestorBytes)}`) + }) + const payment = new Transaction() + payment.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + payment.addOutput({ + satoshis: args.outputs![0].satoshis, + lockingScript: Script.fromHex(args.outputs![0].lockingScript) + }) + prepared.push(payment.id('hex')) + return { txid: payment.id('hex'), tx: payment.toAtomicBEEF() } + }) + const client = new AuthFetch(wallet) + const internal = client as any + jest.spyOn(internal, 'logPaymentAttempt').mockImplementation(() => {}) + jest.spyOn(internal, 'wait').mockResolvedValue(undefined) + const send = jest.spyOn(client, 'fetch').mockResolvedValue(new Response('ok')) + const response = (transports?: string) => + new Response('', { + status: 402, + headers: { + 'x-bsv-payment-version': '1.0', + 'x-bsv-payment-satoshis-required': '10', + 'x-bsv-payment-derivation-prefix': prefix, + 'x-bsv-auth-identity-key': identity, + ...(transports === undefined ? {} : { 'x-bsv-payment-transports': transports }) + } + }) + const pay = (config = {}, advertisement?: string) => + internal.handlePaymentAndRetry( + 'https://payment.example/paid?q=unchanged', + config, + response(advertisement) + ) + return { client, internal, wallet, prepared, send, response, pay } +} + +afterEach(() => jest.restoreAllMocks()) + +describe('AuthFetch prepared BRC-118 payments', () => { + it('prepares without broadcast, keeps small payments in headers, then submits exactly once', async () => { + const { pay, wallet, send, prepared } = setup() + await pay() + expect(wallet.createAction).toHaveBeenCalledTimes(2) + expect(wallet.createAction).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + options: expect.objectContaining({ noSend: true, randomizeOutputs: false }) + }), + undefined + ) + expect(wallet.createAction).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + options: { sendWith: [prepared[0]], acceptDelayedBroadcast: false } + }), + undefined + ) + expect(send.mock.calls[0][1]!.headers!['x-bsv-payment']).toContain('transaction') + }) + it('wraps growing BEEF with the original bytes and media type', async () => { + const { pay, send, wallet } = setup(12_000) + await pay( + { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: '{ "utf8": "雪" }\n' + }, + 'header,multipart' + ) + const config = send.mock.calls[0][1]! + expect(config.method).toBe('POST') + expect(config.headers!['content-type']).toMatch(/^multipart\/form-data; boundary=/) + expect(config.headers!['x-bsv-payment']).toBeUndefined() + expect(Buffer.from(config.body).toString()).toContain( + 'Content-Type: application/json; charset=utf-8\r\n\r\n{ "utf8": "雪" }\n' + ) + expect(wallet.abortAction).not.toHaveBeenCalled() + }) + it.each([ + [{ method: 'POST' }, undefined], + [{ method: 'GET' }, 'header,multipart'], + [{ method: 'HEAD' }, 'header,multipart'], + [{ method: 'POST', paymentTransport: { maxBodyBytes: 1000 } }, 'header,multipart'], + [{ method: 'POST', paymentTransport: { maxPaymentBytes: 1000 } }, 'header,multipart'] + ])( + 'refuses an infeasible prepared payment before broadcasting %#', + async (config, advertisement) => { + const { pay, wallet, send, prepared } = setup(12_000) + await expect(pay(config, advertisement)).rejects.toMatchObject({ + retryable: false, + payment: { txid: expect.any(String), state: 'prepared', aborted: true } + }) + expect(wallet.createAction).toHaveBeenCalledTimes(1) + expect(wallet.abortAction).toHaveBeenCalledWith({ reference: prepared[0] }, undefined) + expect(send).not.toHaveBeenCalled() + } + ) + it('refuses unknown transport advertisements before touching the wallet', async () => { + const { pay, wallet } = setup() + await expect(pay({}, 'unknown')).rejects.toMatchObject({ code: 'ERR_PAYMENT_TRANSPORT' }) + expect(wallet.createAction).not.toHaveBeenCalled() + }) + it('reuses one payment and one frozen multipart body after a lost response', async () => { + const { pay, send, wallet } = setup(12_000) + send + .mockRejectedValueOnce(new Error('connection lost')) + .mockResolvedValueOnce(new Response('ok')) + await pay({ method: 'POST' }, 'header,multipart') + expect(wallet.createAction).toHaveBeenCalledTimes(2) + expect(send).toHaveBeenCalledTimes(2) + expect(send.mock.calls[0][1]!.body).toBe(send.mock.calls[1][1]!.body) + expect(send.mock.calls[0][1]!.headers).toBe(send.mock.calls[1][1]!.headers) + }) + it('never creates another spend when a paid retry receives a different challenge', async () => { + const { pay, send, internal, response, wallet } = setup() + send.mockImplementationOnce(async (url, config) => { + const changed = response('header,multipart') + changed.headers.set('x-bsv-payment-satoshis-required', '11') + return internal.handlePaymentAndRetry(url, config, changed) + }) + await expect(pay()).rejects.toMatchObject({ + code: 'ERR_PAYMENT_REQUIREMENTS_CHANGED', + payment: { state: 'submitted' } + }) + expect(wallet.createAction).toHaveBeenCalledTimes(2) + expect(wallet.abortAction).not.toHaveBeenCalled() + }) + it.each([true, false])( + 'treats size refusal as terminal, authenticated=%s', + async authenticated => { + const { pay, send, wallet } = setup() + if (authenticated) send.mockResolvedValueOnce(new Response('', { status: 413 })) + else + send.mockRejectedValueOnce( + Object.assign(new Error('proxy refused'), { details: { status: 431 } }) + ) + await expect(pay()).rejects.toMatchObject({ + code: 'ERR_PAYMENT_SIZE', + authenticated, + payment: { state: 'submitted' } + }) + expect(send).toHaveBeenCalledTimes(1) + expect(wallet.createAction).toHaveBeenCalledTimes(2) + expect(wallet.abortAction).not.toHaveBeenCalled() + } + ) + it('preserves an uncertain broadcast outcome without sending or automatically aborting', async () => { + const { pay, wallet, send } = setup() + const original = wallet.createAction + wallet.createAction = jest.fn(async args => { + if (args.options?.sendWith !== undefined) throw new Error('ack lost') + return original(args) + }) + await expect(pay()).rejects.toMatchObject({ + code: 'ERR_PAYMENT_OUTCOME_UNKNOWN', + payment: { state: 'uncertain' } + }) + expect(send).not.toHaveBeenCalled() + expect(wallet.abortAction).not.toHaveBeenCalled() + }) + it.each(['prepare', 'submit', 'retry'])( + 'cancels during %s without creating an extra spend', + async phase => { + const { pay, wallet, send } = setup() + const controller = new AbortController() + const original = wallet.createAction + wallet.createAction = jest.fn(async args => { + const result = await original(args) + if ( + (phase === 'prepare' && args.options?.noSend) || + (phase === 'submit' && args.options?.sendWith) + ) + controller.abort() + return result + }) + if (phase === 'retry') + send.mockImplementationOnce(async () => { + controller.abort() + throw new Error('lost response') + }) + await expect(pay({ signal: controller.signal })).rejects.toMatchObject({ + code: 'ERR_PAYMENT_CANCELLED', + payment: { state: phase === 'prepare' ? 'prepared' : 'submitted' } + }) + expect(wallet.createAction).toHaveBeenCalledTimes(phase === 'prepare' ? 1 : 2) + expect(wallet.abortAction).toHaveBeenCalledTimes(phase === 'prepare' ? 1 : 0) + expect(send).toHaveBeenCalledTimes(phase === 'retry' ? 1 : 0) + } + ) + it('changes an advertised transport without creating or submitting another payment', async () => { + const { pay, send, internal, response, wallet } = setup() + send.mockImplementationOnce(async (url, config) => + internal.handlePaymentAndRetry(url, config, response('multipart')) + ) + await pay({ method: 'POST', body: new Uint8Array([0, 1, 2]) }, 'header,multipart') + expect(send.mock.calls[0][1]!.headers!['x-bsv-payment']).toBeDefined() + expect(send.mock.calls[1][1]!.headers!['content-type']).toMatch(/^multipart/) + expect(wallet.createAction).toHaveBeenCalledTimes(2) + }) + it('retains payment context when multipart capability disappears after submission', async () => { + const { pay, send, internal, response, wallet } = setup(12_000) + send.mockImplementationOnce(async (url, config) => + internal.handlePaymentAndRetry(url, config, response('header')) + ) + await expect(pay({ method: 'POST' }, 'header,multipart')).rejects.toMatchObject({ + code: 'ERR_PAYMENT_TRANSPORT', + payment: { state: 'submitted' } + }) + expect(wallet.createAction).toHaveBeenCalledTimes(2) + expect(wallet.abortAction).not.toHaveBeenCalled() + }) + it('releases an identifiable reservation after a malformed wallet result', async () => { + const { pay, wallet, send } = setup() + const original = wallet.createAction + wallet.createAction = jest.fn(async args => ({ ...(await original(args)), tx: [-1] })) + await expect(pay()).rejects.toMatchObject({ + code: 'ERR_PAYMENT_TRANSPORT', + payment: { state: 'prepared', aborted: true } + }) + expect(wallet.createAction).toHaveBeenCalledTimes(1) + expect(wallet.abortAction).toHaveBeenCalledTimes(1) + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.test.ts index c3c62a4ab..5441d62ae 100644 --- a/packages/sdk/src/auth/clients/__tests__/AuthFetch.test.ts +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.test.ts @@ -1,6 +1,8 @@ import { jest } from '@jest/globals' import { AuthFetch } from '../AuthFetch.js' import { Utils, PrivateKey } from '../../../primitives/index.js' +import { paymentActionResult, legacyPaymentBase64 } from '../__tests/paymentFixtures.js' +import { Beef } from '../../../transaction/Beef.js' jest.mock('../../utils/createNonce.js', () => ({ createNonce: jest.fn() @@ -49,9 +51,8 @@ function createWalletStub(): any { } return { publicKey: derivedKey } }), - createAction: jest.fn(async () => ({ - tx: Utils.toArray('mock-transaction', 'utf8') - })), + createAction: jest.fn(paymentActionResult), + abortAction: jest.fn(async () => ({ aborted: true })), createHmac: jest.fn(async () => ({ hmac: Array.from({ length: 32 }).fill(7) })) @@ -99,9 +100,9 @@ describe('AuthFetch payment handling', () => { expect(context.serverIdentityKey).toBe('remote-identity-key') expect(context.derivationPrefix).toBe('test-prefix') expect(context.derivationSuffix).toBe('suffix-from-test') - expect(context.transactionBase64).toBe( - Utils.toBase64(Utils.toArray('mock-transaction', 'utf8')) - ) + expect( + Beef.fromBinaryStrict(Utils.toArray(context.transactionBase64, 'base64')).atomicTxid + ).toEqual(expect.any(String)) expect(context.clientIdentityKey).toEqual(expect.any(String)) expect(context.attempts).toBe(0) expect(context.maxAttempts).toBe(3) @@ -121,7 +122,7 @@ describe('AuthFetch payment handling', () => { expect(wallet.createAction).toHaveBeenCalledWith( expect.objectContaining({ - description: expect.stringContaining('https://api.example.com'), + description: 'BRC-105 HTTP request payment', labels: [`brc105 ${prefixHex} ${suffixHex}`], outputs: [ expect.objectContaining({ @@ -177,7 +178,7 @@ describe('AuthFetch payment handling', () => { 'remote-identity-key', 'test-prefix' ) - ).rejects.toThrow('dense byte array') + ).rejects.toMatchObject({ code: 'ERR_PAYMENT_TRANSPORT' }) }) test('brc105 payment label hex survives lowercasing and round-trips to base64', () => { @@ -201,7 +202,7 @@ describe('AuthFetch payment handling', () => { const paymentContext: TestPaymentContext = { satoshisRequired: 5, - transactionBase64: Utils.toBase64([1, 2, 3]), + transactionBase64: legacyPaymentBase64, derivationPrefix: 'prefix', derivationSuffix: 'suffix', serverIdentityKey: 'server-key', @@ -248,7 +249,7 @@ describe('AuthFetch payment handling', () => { expect(paymentHeader).toEqual({ derivationPrefix: 'prefix', derivationSuffix: 'suffix', - transaction: Utils.toBase64([1, 2, 3]) + transaction: legacyPaymentBase64 }) expect(createPaymentContextSpy).not.toHaveBeenCalled() @@ -269,7 +270,7 @@ describe('AuthFetch payment handling', () => { const paymentContext: TestPaymentContext = { satoshisRequired: 5, - transactionBase64: Utils.toBase64([9, 9, 9]), + transactionBase64: legacyPaymentBase64, derivationPrefix: 'prefix', derivationSuffix: 'suffix', serverIdentityKey: 'server-key', @@ -314,13 +315,13 @@ describe('AuthFetch payment handling', () => { expect(err.details.errors[0]).toEqual( expect.objectContaining({ attempt: 1, - message: 'payment attempt 1 failed' + message: 'Payment delivery failed.' }) ) expect(err.details.errors[1]).toEqual( expect.objectContaining({ attempt: 2, - message: 'payment attempt 2 failed' + message: 'Payment delivery failed.' }) ) expect(typeof err.details.errors[0].timestamp).toBe('string') diff --git a/packages/sdk/src/auth/utils/__tests/decodePaymentPayload.test.ts b/packages/sdk/src/auth/utils/__tests/decodePaymentPayload.test.ts new file mode 100644 index 000000000..e8c39276b --- /dev/null +++ b/packages/sdk/src/auth/utils/__tests/decodePaymentPayload.test.ts @@ -0,0 +1,29 @@ +import { decodePaymentPayload } from '../decodePaymentPayload' + +const bytes = (text: string) => new TextEncoder().encode(text) +describe('authenticated payload decoding', () => { + it('distinguishes absent, empty, text, JSON and opaque nested bodies', () => { + expect(decodePaymentPayload(undefined, 'application/json')).toBeUndefined() + expect(decodePaymentPayload(bytes(''), 'text/plain')).toBe('') + expect(decodePaymentPayload(bytes('雪'), 'text/plain; charset=utf-8')).toBe('雪') + expect(decodePaymentPayload(bytes('{ "a": 1 }\n'), 'application/problem+json')).toEqual({ + a: 1 + }) + const binary = new Uint8Array([0, 255, 128]) + expect(decodePaymentPayload(binary, 'multipart/form-data; boundary=inner')).toBe(binary) + expect(decodePaymentPayload(binary, undefined)).toBe(binary) + expect(() => decodePaymentPayload(binary, 'text/plain')).toThrow() + expect(() => decodePaymentPayload(bytes('{'), 'application/json')).toThrow() + }) + it('retains duplicate form fields and prototype-shaped names without quadratic copying', () => { + const count = 20_000 + const form = decodePaymentPayload( + bytes(`__proto__=safe&constructor=value&${'x=1&'.repeat(count)}`), + 'application/x-www-form-urlencoded' + ) as Record + expect(Object.getPrototypeOf(form)).toBeNull() + expect(form.__proto__).toBe('safe') + expect(form.constructor).toBe('value') + expect(form.x).toHaveLength(count) + }) +}) diff --git a/packages/sdk/src/auth/utils/__tests/paymentTransport.test.ts b/packages/sdk/src/auth/utils/__tests/paymentTransport.test.ts new file mode 100644 index 000000000..08004b76f --- /dev/null +++ b/packages/sdk/src/auth/utils/__tests/paymentTransport.test.ts @@ -0,0 +1,83 @@ +import { + preparePaymentTransport, + authenticatedContentType, + buildMultipartPayment, + findPaymentBytes, + paymentBoundary, + paymentTransports, + resolvePaymentTransportLimits +} from '../paymentTransport.js' +import fc from 'fast-check' + +describe('BRC-118 transport selection primitives', () => { + it('preserves legacy content types and binds the exact multipart boundary', () => { + expect(authenticatedContentType('application/json; charset=utf-8')).toBe('application/json') + const multipart = 'multipart/form-data; boundary="ABC"' + expect(authenticatedContentType(multipart)).toBe(multipart) + expect(paymentBoundary(multipart)).toBe('ABC') + }) + it('treats absent capabilities as header-only and ignores unknown tokens', () => { + expect([...paymentTransports(null)]).toEqual(['header']) + expect([...paymentTransports('header, unknown, multipart, header')]).toEqual([ + 'header', + 'multipart' + ]) + expect([...paymentTransports('unknown')]).toEqual([]) + expect(() => paymentTransports('x'.repeat(257))).toThrow() + }) + it('validates configurable budgets before wallet work', () => { + expect(resolvePaymentTransportLimits().maxPaymentHeaderBytes).toBe(8192) + for (const maximum of [0, -1, Infinity, 1.5, 8193]) { + expect(() => resolvePaymentTransportLimits({ maxPaymentHeaderBytes: maximum })).toThrow() + } + }) + it('matches native byte search even for adversarial repeated prefixes', () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 1000 }), + fc.uint8Array({ minLength: 1, maxLength: 70 }), + (bytes, needle) => { + expect(findPaymentBytes(bytes, needle)).toBe(Buffer.from(bytes).indexOf(needle)) + } + ) + ) + expect( + findPaymentBytes( + new Uint8Array(100_000).fill(65), + new Uint8Array([...Array(69).fill(65), 66]) + ) + ).toBe(-1) + }) + it('bounds the final body including framing and rejects media-type injection', () => { + const built = buildMultipartPayment('{}', undefined, 1000, 'fixed') + expect(() => buildMultipartPayment('{}', undefined, built.body.length - 1, 'fixed')).toThrow( + 'body limit' + ) + expect(() => + buildMultipartPayment( + '{}', + { bytes: new Uint8Array(), contentType: 'text/plain\r\nInjected: true' }, + 1000 + ) + ).toThrow() + }) + it('selects exactly at the header threshold and enforces aggregate header limits', () => { + const original = { method: 'POST', headers: {} } + const transports = new Set(['header', 'multipart']) + const limits = resolvePaymentTransportLimits() + expect(preparePaymentTransport('x'.repeat(8192), original, transports, limits).transport).toBe( + 'header' + ) + expect(preparePaymentTransport('x'.repeat(8193), original, transports, limits).transport).toBe( + 'multipart' + ) + expect(() => + preparePaymentTransport( + '{}', + { ...original, headers: { 'x-large': 'x'.repeat(16384) } }, + transports, + limits + ) + ).toThrow('aggregate header budget') + }) +}) diff --git a/packages/sdk/src/auth/utils/decodePaymentPayload.ts b/packages/sdk/src/auth/utils/decodePaymentPayload.ts new file mode 100644 index 000000000..d526682f9 --- /dev/null +++ b/packages/sdk/src/auth/utils/decodePaymentPayload.ts @@ -0,0 +1,24 @@ +import { toUTF8Strict } from '../../primitives/utils.js' + +/** Decode an authenticated application payload while retaining its separately owned raw bytes. */ +export function decodePaymentPayload( + body: Uint8Array | undefined, + contentType: string | undefined +): unknown { + if (body === undefined) return undefined + const mediaType = contentType?.split(';')[0].trim().toLowerCase() + if (mediaType === 'application/json' || mediaType?.endsWith('+json') === true) + return JSON.parse(toUTF8Strict(body)) as unknown + if (mediaType?.startsWith('text/') === true) return toUTF8Strict(body) + if (mediaType === 'application/x-www-form-urlencoded') { + const values = Object.create(null) as Record + for (const [key, value] of new URLSearchParams(toUTF8Strict(body))) { + const prior = values[key] + if (prior === undefined) values[key] = value + else if (Array.isArray(prior)) prior.push(value) + else values[key] = [prior, value] + } + return values + } + return body +} diff --git a/packages/sdk/src/auth/utils/index.ts b/packages/sdk/src/auth/utils/index.ts index 5c0df10ce..a67501d99 100644 --- a/packages/sdk/src/auth/utils/index.ts +++ b/packages/sdk/src/auth/utils/index.ts @@ -2,3 +2,4 @@ export * from './verifyNonce.js' export * from './createNonce.js' export * from './getVerifiableCertificates.js' export * from './validateCertificates.js' +export * from './paymentTransport.js' diff --git a/packages/sdk/src/auth/utils/paymentTransport.ts b/packages/sdk/src/auth/utils/paymentTransport.ts new file mode 100644 index 000000000..5f7897b97 --- /dev/null +++ b/packages/sdk/src/auth/utils/paymentTransport.ts @@ -0,0 +1,254 @@ +import Random from '../../primitives/Random.js' +import { toArray, toHex } from '../../primitives/utils.js' + +export interface PaymentTransportLimits { + /** Selection threshold, not a guarantee about a particular proxy. Default 8 KiB. */ + maxPaymentHeaderBytes?: number + /** Includes a 4 KiB reserve for authentication and HTTP framing. Default 16 KiB. */ + maxRequestHeaderBytes?: number + /** Maximum serialized payment JSON. Default 4 MiB. */ + maxPaymentBytes?: number + /** Complete transmitted request body, including multipart framing. Default 7 MiB. */ + maxBodyBytes?: number +} + +export interface ResolvedPaymentTransportLimits { + maxPaymentHeaderBytes: number + maxRequestHeaderBytes: number + maxPaymentBytes: number + maxBodyBytes: number +} + +export type PaymentTransportErrorCode = + | 'ERR_PAYMENT_SIZE' + | 'ERR_PAYMENT_TRANSPORT' + | 'ERR_PAYMENT_REQUIREMENTS_CHANGED' + | 'ERR_PAYMENT_OUTCOME_UNKNOWN' + | 'ERR_PAYMENT_CANCELLED' + +/** Permanent transport refusals are not retried automatically. No payment bytes are attached. */ +export class PaymentTransportError extends Error { + readonly retryable = false + constructor( + readonly code: PaymentTransportErrorCode, + message: string, + readonly payment?: { + txid: string + state: 'prepared' | 'submitted' | 'uncertain' + aborted?: boolean + }, + readonly httpStatus?: number, + readonly authenticated?: boolean + ) { + super(message) + this.name = 'PaymentTransportError' + } +} + +export function resolvePaymentTransportLimits( + options: PaymentTransportLimits = {} +): ResolvedPaymentTransportLimits { + const limits = { + maxPaymentHeaderBytes: options.maxPaymentHeaderBytes ?? 8192, + maxRequestHeaderBytes: options.maxRequestHeaderBytes ?? 16 * 1024, + maxPaymentBytes: options.maxPaymentBytes ?? 4 * 1024 * 1024, + maxBodyBytes: options.maxBodyBytes ?? 7 * 1024 * 1024 + } + for (const [key, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < 1 || value > 16 * 1024 * 1024) { + throw new RangeError(`${key} must be an integer from 1 through 16777216`) + } + } + // The released authenticated transport has a fixed per-header ceiling. + if (limits.maxPaymentHeaderBytes > 8192) + throw new RangeError('maxPaymentHeaderBytes cannot exceed 8192') + return limits +} + +export function paymentTransports(advertisement: string | null): ReadonlySet { + if (advertisement === null) return new Set(['header']) + if (advertisement.length > 256) + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Payment transport advertisement is too large.' + ) + return new Set( + advertisement + .split(',') + .map(value => value.trim()) + .filter(value => value === 'header' || value === 'multipart') + ) +} + +/** Keep released non-multipart preimages; bind the exact multipart boundary and parameters. */ +export function authenticatedContentType(value: string): string { + return isMultipartPaymentType(value) ? value : value.split(';')[0].trim() +} + +export function isMultipartPaymentType(value: string): boolean { + return value.split(';')[0].trim().toLowerCase() === 'multipart/form-data' +} + +export function paymentBoundary(contentType: string): string { + // One unambiguous boundary parameter; quoted MIME token boundaries are accepted. + const match = + /^multipart\/form-data\s*;\s*boundary=(?:([A-Za-z0-9'()+_,./:=?-]{1,70})|"([A-Za-z0-9'()+_,./:=?-]{1,70})")\s*$/i.exec( + contentType + ) + if (match == null) + throw new PaymentTransportError('ERR_PAYMENT_TRANSPORT', 'Invalid multipart payment boundary.') + return match[1] ?? match[2] +} + +export function paymentPayloadContentType(value: string): string { + if ( + value.length === 0 || + value.length > 1024 || + /[^\x20-\x7e]/.test(value) || + !/^[!#$%&'*+.^_`|~\w-]+\/[!#$%&'*+.^_`|~\w-]+(?:\s*;.*)?$/.test(value) + ) { + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Invalid original payload Content-Type.' + ) + } + return value +} + +/** Linear-time byte search, also used to rule out delimiter collisions before signing. */ +export function findPaymentBytes(bytes: Uint8Array, needle: Uint8Array, from = 0): number { + const prefix = new Uint32Array(needle.length) + for (let index = 1, length = 0; index < needle.length;) { + if (needle[index] === needle[length]) prefix[index++] = ++length + else if (length > 0) length = prefix[length - 1] + else prefix[index++] = 0 + } + for (let index = from, length = 0; index < bytes.length;) { + if (bytes[index] === needle[length]) { + index++ + if (++length === needle.length) return index - length + } else if (length > 0) length = prefix[length - 1] + else index++ + } + return -1 +} + +export interface MultipartPaymentBody { + contentType: string + body: Uint8Array +} + +export function preparePaymentTransport( + paymentJSON: string, + original: { method: string; headers: Record; body?: Uint8Array }, + transports: ReadonlySet, + limits: ResolvedPaymentTransportLimits +): { headers: Record; body?: Uint8Array; transport: 'header' | 'multipart' } { + const paymentSize = toArray(paymentJSON, 'utf8').length + if (paymentSize > limits.maxPaymentBytes) + throw new PaymentTransportError( + 'ERR_PAYMENT_SIZE', + 'Payment JSON exceeds its configured limit.' + ) + const headers: Record = Object.create(null) + for (const [name, value] of Object.entries(original.headers)) { + const lower = name.toLowerCase() + if (Object.hasOwn(headers, lower)) + throw new PaymentTransportError('ERR_PAYMENT_TRANSPORT', 'Duplicate request header.') + if (lower !== 'x-bsv-payment') headers[lower] = value + } + const originalType = headers['content-type'] + let body = original.body + let transport: 'header' | 'multipart' = 'header' + if ( + paymentSize > limits.maxPaymentHeaderBytes || + !transports.has('header') || + (originalType !== undefined && isMultipartPaymentType(originalType)) + ) { + if (!transports.has('multipart')) + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'This payment requires multipart support from the authenticated server.' + ) + if (['GET', 'HEAD'].includes(original.method.toUpperCase())) + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Fetch cannot carry a multipart payment with GET or HEAD; use a server-supported body-bearing route.' + ) + const multipart = buildMultipartPayment( + paymentJSON, + body === undefined + ? undefined + : { bytes: body, contentType: originalType ?? 'application/octet-stream' }, + limits.maxBodyBytes + ) + headers['content-type'] = multipart.contentType + body = multipart.body + transport = 'multipart' + } else headers['x-bsv-payment'] = paymentJSON + if (body !== undefined && body.length > limits.maxBodyBytes) + throw new PaymentTransportError('ERR_PAYMENT_SIZE', 'Paid request exceeds the body limit.') + const headerBytes = Object.entries(headers).reduce( + (total, [name, value]) => + total + toArray(name, 'utf8').length + toArray(value, 'utf8').length + 4, + 4096 + ) + if (headerBytes > limits.maxRequestHeaderBytes) + throw new PaymentTransportError( + 'ERR_PAYMENT_SIZE', + 'Paid request exceeds the aggregate header budget.' + ) + return { headers, body, transport } +} + +/** Serialize once, then sign and transmit these same owned bytes. Never pass native FormData. */ +export function buildMultipartPayment( + paymentJSON: string, + payload: { bytes: Uint8Array; contentType: string } | undefined, + maximumBytes: number, + boundary = `----BsvPayment${toHex(Random(16))}` +): MultipartPaymentBody { + const contentType = `multipart/form-data; boundary=${boundary}` + paymentBoundary(contentType) + const utf8 = (text: string): Uint8Array => new Uint8Array(toArray(text, 'utf8')) + const payment = utf8(paymentJSON) + const delimiter = utf8(`--${boundary}`) + if ( + findPaymentBytes(payment, delimiter) !== -1 || + (payload !== undefined && findPaymentBytes(payload.bytes, delimiter) !== -1) + ) { + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Multipart boundary collides with payload.' + ) + } + const pieces = [ + utf8( + `--${boundary}\r\nContent-Disposition: form-data; name="x-bsv-payment"\r\nContent-Type: application/json\r\n\r\n` + ), + payment + ] + if (payload !== undefined) { + pieces.push( + utf8( + `\r\n--${boundary}\r\nContent-Disposition: form-data; name="body"\r\nContent-Type: ${paymentPayloadContentType(payload.contentType)}\r\n\r\n` + ), + payload.bytes + ) + } + pieces.push(utf8(`\r\n--${boundary}--\r\n`)) + const length = pieces.reduce((total, piece) => total + piece.length, 0) + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || length > maximumBytes) { + throw new PaymentTransportError( + 'ERR_PAYMENT_SIZE', + 'Multipart payment exceeds the request body limit.' + ) + } + const body = new Uint8Array(length) + let offset = 0 + for (const piece of pieces) { + body.set(piece, offset) + offset += piece.length + } + return { contentType, body } +} diff --git a/packages/sdk/src/remittance/__tests/BasicBRC29.interop.test.ts b/packages/sdk/src/remittance/__tests/BasicBRC29.interop.test.ts new file mode 100644 index 000000000..8ce7e4e6d --- /dev/null +++ b/packages/sdk/src/remittance/__tests/BasicBRC29.interop.test.ts @@ -0,0 +1,63 @@ +import { Brc29RemittanceModule } from '../modules/BasicBRC29.js' +import { ProtoWallet } from '../../wallet/ProtoWallet.js' +import type { WalletInterface } from '../../wallet/Wallet.interfaces.js' +import PrivateKey from '../../primitives/PrivateKey.js' +import PublicKey from '../../primitives/PublicKey.js' +import Transaction from '../../transaction/Transaction.js' +import Script from '../../script/Script.js' +import P2PKH from '../../script/templates/P2PKH.js' + +const payerKey = new PrivateKey(71) +const recipientKey = new PrivateKey(72) +const payer = new ProtoWallet(payerKey) +const recipient = new ProtoWallet(recipientKey) +const protocolID: [2, string] = [2, '3241645161d8'] +const prefix = 'cHJlZml4' +const suffix = 'c3VmZml4' + +async function artifact(payToSender: boolean) { + const key = await payer.getPublicKey({ + protocolID, + keyID: `${prefix} ${suffix}`, + counterparty: recipientKey.toPublicKey().toString(), + forSelf: payToSender + }) + const source = new Transaction() + source.addOutput({ satoshis: 1000, lockingScript: Script.fromASM('OP_TRUE') }) + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + tx.addOutput({ + satoshis: 100, + lockingScript: new P2PKH().lock(PublicKey.fromString(key.publicKey).toAddress()) + }) + return { + customInstructions: { derivationPrefix: prefix, derivationSuffix: suffix }, + transaction: tx.toAtomicBEEF(), + amountSatoshis: 100, + outputIndex: 0 + } +} + +describe('BRC-29 independent payer and recipient key derivation', () => { + it.each([false, true])( + 'accepts only a recipient-spendable output; pays sender=%s', + async paysSender => { + const wallet = recipient as unknown as WalletInterface + wallet.internalizeAction = jest.fn(async () => ({ accepted: true })) + const result = await new Brc29RemittanceModule().acceptSettlement( + { + threadId: 'two-wallet-interop', + sender: payerKey.toPublicKey().toString(), + settlement: await artifact(paysSender) + }, + { wallet, now: () => 1 } + ) + expect(result.action).toBe(paysSender ? 'terminate' : 'accept') + expect(wallet.internalizeAction).toHaveBeenCalledTimes(paysSender ? 0 : 1) + } + ) +}) diff --git a/packages/sdk/src/remittance/modules/BasicBRC29.ts b/packages/sdk/src/remittance/modules/BasicBRC29.ts index 9cef869c6..01597dfce 100644 --- a/packages/sdk/src/remittance/modules/BasicBRC29.ts +++ b/packages/sdk/src/remittance/modules/BasicBRC29.ts @@ -344,7 +344,7 @@ export class Brc29RemittanceModule implements RemittanceModule< } const keyID = `${settlement.customInstructions.derivationPrefix} ${settlement.customInstructions.derivationSuffix}` - const keyRequest = { protocolID, keyID, counterparty: sender } + const keyRequest = { protocolID, keyID, counterparty: sender, forSelf: true } const { publicKey } = validateWalletResult( 'getPublicKey', await wallet.getPublicKey(keyRequest, origin), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c39ef3776..73583f5ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -968,6 +968,9 @@ importers: '@typescript/native': specifier: npm:typescript@7.0.2 version: typescript@7.0.2 + esbuild: + specifier: 0.28.1 + version: 0.28.1 express: specifier: ^5.2.1 version: 5.2.1 @@ -980,6 +983,9 @@ importers: oxlint: specifier: ^1.76.0 version: 1.76.0 + puppeteer-core: + specifier: ^25.4.0 + version: 25.4.0(yauzl@3.4.0) ts-jest: specifier: ^29.4.12 version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(@typescript/typescript6@6.0.2)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2))) @@ -1923,6 +1929,9 @@ importers: oxlint: specifier: ^1.76.0 version: 1.76.0 + puppeteer-core: + specifier: ^25.4.0 + version: 25.4.0(yauzl@3.4.0) tsdown: specifier: 0.22.14 version: 0.22.14(@arethetypeswrong/core@0.18.5)(@typescript/typescript6@6.0.2)(publint@0.3.22)(tsx@4.23.1) diff --git a/scripts/brc100-byte-boundary.test.mjs b/scripts/brc100-byte-boundary.test.mjs index d74928866..8fb350b5e 100644 --- a/scripts/brc100-byte-boundary.test.mjs +++ b/scripts/brc100-byte-boundary.test.mjs @@ -103,7 +103,8 @@ test('the byte compatibility contract remains part of the public SDK wallet API' }) const dependentPackageSdkRanges = [ - ['packages/middleware/auth-express-middleware/package.json', '^2.7.1'], + ['packages/middleware/auth-express-middleware/package.json', '^2.9.0'], + ['packages/middleware/payment-express-middleware/package.json', '^2.9.0'], ['packages/messaging/authsocket-client/package.json', '^2.4.1'], ['packages/messaging/authsocket/package.json', '^2.4.1'], ['packages/messaging/message-box-client/package.json', '^2.4.1'], From 2aea363f46f169f5a72f045c5397a4573ec53080 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 05:32:40 -0700 Subject: [PATCH 002/127] feat(wallet): resume bounded sync with fair access and canonical proof recovery --- docs/guides/wallet-sync-reliability.md | 225 +++++++ packages/wallet/wallet-toolbox/CHANGELOG.md | 16 + packages/wallet/wallet-toolbox/README.md | 102 +++- .../create-action-beef.bench.test.ts | 208 +++---- .../benchmarks/resumable-sync.bench.test.ts | 248 ++++++++ .../wallet-toolbox/benchmarks/sync-fixture.ts | 71 +++ .../wallet/wallet-toolbox/client/README.md | 14 + .../wallet/wallet-toolbox/client/package.json | 10 +- .../client/test/sync-browser.mjs | 60 ++ .../client/test/sync-browser.ts | 187 ++++++ .../wallet/wallet-toolbox/mobile/README.md | 14 + .../wallet/wallet-toolbox/mobile/package.json | 2 +- .../mobile/test/payment-transport.test.ts | 52 ++ packages/wallet/wallet-toolbox/package.json | 4 +- .../src/sdk/ActionBatch.interfaces.ts | 7 + .../src/sdk/WalletErrorFromJson.ts | 13 + .../src/sdk/WalletStorage.interfaces.ts | 24 +- .../src/sdk/__test/WalletError.test.ts | 24 + .../wallet-toolbox/src/storage/StorageIdb.ts | 37 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 30 + .../src/storage/StorageProvider.ts | 74 ++- .../src/storage/WalletStorageManager.ts | 555 +++++++++--------- .../__test/WalletStorageManager.test.ts | 84 ++- .../wallet-toolbox/src/storage/index.all.ts | 1 + .../src/storage/index.client.ts | 1 + .../src/storage/index.mobile.ts | 1 + .../methods/attemptToPostReqsToNetwork.ts | 4 + .../src/storage/methods/createAction.ts | 6 + .../src/storage/methods/processAction.ts | 54 +- .../src/storage/methods/proofWork.test.ts | 59 ++ .../src/storage/methods/proofWork.ts | 38 ++ .../src/storage/methods/refreshSyncProof.ts | 49 ++ .../storage/methods/repairBeefProofs.test.ts | 535 +++++++++++++++++ .../src/storage/methods/repairBeefProofs.ts | 97 +++ .../src/storage/methods/validateSyncProof.ts | 16 + .../storage/remoting/validateRpcSyncProofs.ts | 49 +- .../schema/entities/EntitySyncState.ts | 8 +- .../entities/__tests/ProvenTxTests.test.ts | 92 +-- .../storage/sync/StorageAccessQueue.test.ts | 95 +++ .../src/storage/sync/StorageAccessQueue.ts | 55 ++ .../src/storage/sync/SyncPageBudget.test.ts | 63 +- .../src/storage/sync/SyncPageBudget.ts | 83 ++- .../src/storage/sync/snapshotSyncPage.ts | 44 ++ .../src/storage/sync/syncCheckpoint.ts | 7 + .../src/storage/sync/syncFailure.test.ts | 128 ++++ .../src/storage/sync/syncFailure.ts | 35 ++ .../src/storage/sync/syncSession.test.ts | 405 +++++++++++++ .../src/storage/sync/syncSession.ts | 165 ++++++ .../test/storage/processAction.test.ts | 87 +-- 49 files changed, 3625 insertions(+), 613 deletions(-) create mode 100644 docs/guides/wallet-sync-reliability.md create mode 100644 packages/wallet/wallet-toolbox/benchmarks/resumable-sync.bench.test.ts create mode 100644 packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts create mode 100644 packages/wallet/wallet-toolbox/client/test/sync-browser.mjs create mode 100644 packages/wallet/wallet-toolbox/client/test/sync-browser.ts create mode 100644 packages/wallet/wallet-toolbox/mobile/test/payment-transport.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/methods/proofWork.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/methods/refreshSyncProof.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md new file mode 100644 index 000000000..708f9b1fe --- /dev/null +++ b/docs/guides/wallet-sync-reliability.md @@ -0,0 +1,225 @@ +--- +id: wallet-sync-reliability +title: 'Resumable wallet synchronization and proof recovery' +kind: guide +version: '1.0.0' +last_updated: '2026-09-23' +last_verified: '2026-09-23' +review_cadence_days: 30 +status: beta +tags: [wallet, sync, storage, performance] +--- + +# Resumable wallet synchronization and proof recovery + +The unpublished Toolbox 2.14 candidate provides a complete bounded pull-sync +session for local Knex and IndexedDB destinations. Source requests, proof-provider +I/O and progress callbacks run outside exclusive page ownership; the destination +commits each page and its checkpoint atomically. Foreground reads and writes can +proceed between commits. Remote/custom destinations retain the established +exclusive path unless their local provider explicitly supports the required +capability. Existing `syncFromReader`, `syncToWriter` and their result contracts +remain available. + +## Consumer contract + +```ts +const controller = new AbortController() +const result = await manager.syncFromReaderResumable(identityKey, source, { + signal: controller.signal, + maxItems: 1000, + maxRoughSize: 262144, + onProgress: progress => { + // Render counters and state; keep this callback short and non-throwing. + console.log(progress.state, progress.pages, progress.inserts, progress.updates) + } +}) +``` + +The result reports `completed` or `cancelled`, actual `paged` or `exclusive` +execution, acknowledged page/entity counts and the last durable checkpoint. +Progress includes reading, preparing, committing, committed and terminal states, +with separate source-read, preparation, queue and commit timings when available. +A callback exception rejects the call; resume from the destination rather than +assuming that no page committed. + +Cancellation is cooperative. It waits for an in-flight source read or proof +lookup to drain, discards an uncommitted page, and waits for an already-started +commit acknowledgement. It does not abort a database transaction after its +outcome becomes uncertain or undo acknowledged data. Invoke the API again to +resume; do not use UI counters as a checkpoint. A lost acknowledgement rejects +without automatically replaying a write, and the next session loads the durable +destination checkpoint. + +Only one page is in flight. The new API defaults to 1,000 rows and 262,144 rough +encoded bytes (256 KiB). Options must be positive safe integers, no greater than +1,000 rows and 10,000,000 rough bytes. Existing sync methods retain their prior +defaults. The new default is a measured starting point for constrained clients, +not a universal memory ceiling. A single large record can exceed a rough page target; +the existing separately negotiated 64 MiB transfer-frame bound still applies. +See [bounded transfers](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/wallet/wallet-toolbox/docs/sync-transfer.md). +There is no unbounded prefetch queue or hidden parallel merge. + +## Identity, network and persistence safety + +Known chain mismatches fail with the existing `WERR_NETWORK_CHAIN` before sync +state or user writes. A legacy provider that omits chain information retains its +legacy behavior; absence is not proof of a matching chain. Both sync directions +honor returned `ProcessSyncChunkResult.error` values as well as thrown errors. +Neither path advances counts or checkpoints after an error, and a nonterminal +page that makes no checkpoint progress fails explicitly. + +Sessions pin wallet identity, destination instance and manager generation. +Switching/destroying the destination fences late replies before mutation. The +page commit rechecks the durable checkpoint and any proof rows inspected during +preparation, preventing concurrent sessions or monitor repairs from overwriting +newer state. Input pages and checkpoint values are detached before asynchronous +validation. No new persisted schema or archive envelope is introduced. + +The queue permits at most eight concurrent readers on providers advertising safe +reads. Writers remain exclusive. Foreground work has priority, with bounded +bypass (eight grants or one second of queue age) so background sync can still +progress. Legacy providers remain exclusive. Advertising a capability is a +provider correctness promise, not merely a performance hint. + +The adaptive controller separates fixed page latency from marginal record cost, +uses a bounded sample window, starts with 64 records, limits growth to 2x and +caps proof pages at 128 records and metadata pages at 1,000. A prolonged one-row +floor probes recovery. These bounds prevent the previous fixed-latency feedback +loop from permanently collapsing page size, without treating a slow proof page +as evidence that all later metadata is equally expensive. + +## Canonical proof recovery + +Assembled BEEF is checked before spending or broadcasting. An invalid root can +trigger bounded recovery through the configured chain tracker and active-chain +header/proof provider. A block hash alone does not establish canonicality. +Recovery validates the path, root, height, transaction identity and actual +80-byte header; it preserves transaction bytes and txid-only ancestry. Valid +BEEF takes the fast path without database proof lookups or graph copying. + +At most eight proof lookups run concurrently, and SQL lookups use batches of at +most 250 distinct transaction IDs. Built-in stores atomically compare previous +proof authority fields before persisting a correction and invalidate prepared +BEEF in the same transaction. Delayed monitor responses cannot overwrite newer +proofs. Custom stores lacking compare-and-set support can repair the outgoing +BEEF without claiming that the shared proof record was persisted. + +Unavailable canonical evidence produces `WERR_INVALID_MERKLE_ROOT` with its +existing public/JSON identity and retry guidance. Recovery does not fabricate +success or broadcast a transaction. Historical stores still need independent +operator auditing; an upstream fix is not evidence that every deployed record +was repaired. + +## Timestamp boundaries and snapshot scope + +This API preserves BRC-40's inclusive `since` boundary and existing entity order, +ID mapping, tombstones and merge rules. Records arriving later with the same +last timestamp must remain discoverable. Consequently, a large imported batch +sharing one timestamp can require a complete boundary reread even when it has +no changes. The benchmark deliberately tests this case. Advancing beyond that +boundary without a coherent source contract would lose valid peer changes. + +A successful live merge is an eventual replica, not a point-in-time source +snapshot or authorization to activate a new primary. Existing BRC-38/BRC-39 +export/import contracts are unchanged. Issue #544 remains open for coherent +remote snapshots, streamed archives and consumer adoption. + +## Next-stage design checkpoint (not implemented) + +A future source-snapshot capability should negotiate a versioned contract +separately from ordinary sync and bounded transfer. A snapshot must bind the +authenticated wallet, network, storage identity, source schema, entity ordering +and immutable high-water position. Use a stable per-entity cursor (including a +tie-breaker) within that immutable view; do not reinterpret live timestamp/offset +checkpoints as coherent snapshots. The exact wire names remain subject to shared +conformance review and independent provider implementation. + +Creation must establish the entire snapshot consistently, using a transactional +read view or immutable staging with an atomic manifest. Bound retained snapshots, +bytes, lifetime, per-identity concurrency and cleanup across replicas. A durable +resume token must bind the snapshot digest and cursor, reject another identity, +and fail explicitly after expiry. A restarted reader may resume the same +immutable snapshot or restart a new one; it must never combine both silently. + +Streaming BRC-38 encoding should preserve canonical record order, binaries, +nullable history, source provenance and the standard's deliberate exclusions. +BRC-39 streaming must preserve the released envelope, KDF and authentication: +stage decrypted bytes privately, verify the complete authentication tag and +semantic relationships, then atomically activate an isolated import. Partial +plaintext is never active wallet data. Bound memory, worker/IPC queues, KDF work +and temporary disk independently; record crash recovery and safe cleanup. + +Release gates for that next stage are old/new independent reader/writer vectors, +wrong password/identity/network and corruption cases, same-timestamp/tombstone +relationships, restart at every durable boundary, repeated import, native +browser/mobile evidence and wallet adoption. No partial archive or optimistic +activation should be labeled complete while those gates remain outstanding. + +## Reproducible validation + +The fixture uses 10,000 same-timestamp labels (including tombstones), 64 proofs +with 32 KiB transactions and associated wallet transactions. It compares +exclusive capability fallback with paged mode on native Chromium IndexedDB, +SQLite and authenticated HTTP-to-SQLite. The same source state is restored +between modes. It reports full-copy time, CPU/RSS or browser heap, SQL queries, +response body bytes, foreground percentiles, event-loop delays and page timings. +It also checks unchanged-boundary replay, incremental tombstones and a settled +unchanged copy. A 15 ms source delay tests fixed-overhead behavior. + +```sh +pnpm --filter @bsv/wallet-toolbox bench:storage-sync +pnpm --filter @bsv/wallet-toolbox-client bench:sync +``` + +Native Chrome/Chromium is required (`CHROME_BIN` can select it). Emulator tests +remain useful for deterministic storage faults; fake IndexedDB timings do not +represent browser database performance. The acceptance fixture bounds full-copy +time, memory growth and foreground/event-loop p95, and requires foreground p95 +to improve at least 2x without doubling full-copy time. Those are fixture gates, +not latency guarantees for every wallet, device or provider. + +### September 23 local measurements + +Measured sequentially on macOS arm64, Node 24.19.0 and pnpm 10.33.2, using the +256 KiB fixture above. The control is this branch's exclusive capability fallback; +it isolates scheduling behavior, not every change since the released package. +Foreground latency starts when its timer callback runs. Event-loop delay is +reported separately so synchronous CPU stalls remain visible. The exclusive +control has very few completed foreground samples because it holds the queue. + +| Backend / mode | Full copy ms | Foreground samples | Foreground p50 / p95 / p99 ms | Event-loop p95 / p99 ms | Pages | +| ------------------------------ | -----------: | -----------------: | ----------------------------- | ----------------------- | ----: | +| Chromium IndexedDB / exclusive | 4143.7 | 1 | 4137.90 / 4137.90 / 4137.90 | 1.30 / 1.90 | 45 | +| Chromium IndexedDB / paged | 4277.2 | 393 | 1.00 / 23.60 / 138.40 | 1.20 / 1.50 | 45 | +| sqlite / exclusive | 2413.1 | 1 | 2377.71 / 2377.71 / 2377.71 | 87.98 / 111.34 | 45 | +| sqlite / paged | 2403.5 | 131 | 0.23 / 0.36 / 0.40 | 92.89 / 111.37 | 45 | +| http / exclusive | 22281.5 | 5 | 0.24 / 22190.60 / 22190.60 | 208.04 / 230.11 | 47 | +| http / paged | 22294.3 | 314 | 0.29 / 0.39 / 0.44 | 209.22 / 231.82 | 47 | + +Native browser peak JS heap was 75.8 MB exclusive and 53.9 MB paged. SQLite +full-copy CPU was 1.91/1.83 seconds and sampled RSS growth 94.9/99.6 MB. +Authenticated HTTP CPU was 22.01/22.03 seconds, RSS growth 323.6/269.1 MB, +and response bodies 7,173,923/7,172,956 bytes. Values are exclusive/paged; +HTTP bytes exclude headers, requests and TLS framing. HTTP and SQLite include +source and destination in the same process. Absolute RSS includes the test +runner and previously allocated heap; sampled growth is not a total memory cap. + +SQLite issued 31,612/31,872 SQL queries and HTTP issued 32,315/32,919, including +foreground operations. Extra queries reflect completed foreground work; this +change does not claim a database-query reduction. Paged maximum commit times +were 143 ms (browser), 104 ms (SQLite) and 92 ms (HTTP). Maximum measured paged +queue wait was 1 ms in all three fixtures. + +All modes preserved the full same-timestamp boundary reread: 45 pages locally +and 47 over HTTP; a subsequent settled unchanged copy used two pages. The +network fixture remains CPU-heavy, with about 209 ms event-loop p95 despite +short queue waits. Moving crypto/serialization off the main thread is future +work; queue responsiveness must not be represented as eliminating that cost. +These are reproducible local observations, not cross-device performance promises. + +Adjacent local spending benchmarks also pass: the pathological 178-source BEEF +plan used 17 queries and about 198 ms; the eight-source cold/prepared paths used +14 queries and about 7.3/5.8 ms. The funding, action-batch and legacy storage-sync +benchmark gates pass. Their existing external PXC cohorts require their governed +MySQL environment and were not executed by the default local invocation. diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 7be451e68..31eaf4ac6 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -4,6 +4,22 @@ This document captures the history of significant changes to the wallet-toolbox The git commit history contains the details but is unable to draw attention to changes that materially alter behavior or extend functionality. +## 2.14.0 candidate — bounded synchronization and canonical proof recovery + +- Add resumable local atomic pages, durable checkpoints, cancellation/progress, + concurrent read capability checks and fair foreground/background ownership. +- Separate fixed source/commit latency from marginal row cost and bound upward + probes, page records and proof concurrency. +- Check chains before sync writes and preserve returned, serialized and thrown + failures without advancing progress. +- Repair stale selected/input/broadcast proofs against canonical evidence; fence + monitor updates against primary replacement and concurrent proof corrections. +- Keep proof changes and prepared-BEEF invalidation atomic, and retain safe custom + provider behavior. No persisted-schema migration is required. +- Exercise large copies, tombstones, restart/lost acknowledgements and foreground + latency on SQLite, authenticated HTTP and native Chromium IndexedDB. Inclusive + timestamp boundary traffic remains a snapshot/high-water follow-up. + ## wallet-toolbox (unreleased) - Keep cold raw-transaction reads on the caller's transaction, including SQLite diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index bd799f33b..6dd03b304 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -180,8 +180,13 @@ The toolbox publishes three npm packages from this repo: ### Sync performance and recovery Sync pages start at 64 records and adapt after successful commits toward a -five-second page budget. Proof-bearing pages cap growth at 128 records; cheap -metadata pages can grow to 1,000, while provider byte/item ceilings still apply. +five-second marginal-work budget. A bounded history separates fixed read and +commit overhead from per-record work, so slow fixed latency does not collapse +large copies to one record per request. A two-record probe permits recovery from +the single-record floor. Proof-bearing pages cap growth at 128 records; metadata +pages can grow to 1,000, while provider byte/item ceilings still apply. These are +work estimates, not deadlines: an individual proof or unavailable dependency can +still take longer. The server checks at most eight proofs concurrently and waits for all started checks to settle on failure before rejecting the page. Every proof still passes transaction, Merkle path, active-root and active-header validation before a merge. @@ -204,9 +209,9 @@ client bundle cost. The [artifact measurements and limits](./docs/sync-transfer. include the combined upstream security fixes. These are explicit feature costs; the RPC validation coordinator remains excluded from browser/mobile bundles. -The transfer extension is an **unpublished 2.13.0 candidate**. Published 2.12.0 -has no record-transfer methods. Check exact build provenance and authenticated -runtime capabilities, not a version label alone. An oversized record on a legacy +The transfer extension is included in the published 2.13.2 graph. Older 2.12.0 +providers have no record-transfer methods. Check exact build provenance and +authenticated runtime capabilities, not a version label alone. An oversized record on a legacy source cannot be rescued by upgrading only its destination; upgrade the source before retrying. Records exceeding the negotiated 64 MiB frame limit fail safely without being skipped or advancing their checkpoint. @@ -331,6 +336,93 @@ retained privately, outside this repository. page for both Knex and IndexedDB storage, including short final pages and pages requested at or past the end of the result set. +### Resumable pulls and foreground access + +`WalletStorageManager.syncFromReaderResumable(identityKey, reader, options)` +adds per-page progress and cancellation without changing the existing sync +method signatures. The result reports `completed` or `cancelled`, page and row +counts, the last acknowledged checkpoint, and the selected execution mode. + +The new resumable API defaults to a 256 KiB rough page target, with an explicit +ceiling up to 10 MB. Existing sync methods retain their previous defaults. + +```ts +const cancellation = new AbortController() +const result = await storage.syncFromReaderResumable(identityKey, reader, { + signal: cancellation.signal, + maxItems: 128, + maxRoughSize: 262144, + onProgress: progress => console.log(progress.state, progress.pages) +}) +// A later invocation loads durable destination progress, including any page +// whose acknowledgement was lost. Do not replay a saved request manually. +``` + +Local SQLite/MySQL and IndexedDB destinations advertise `storageAccess.version` +1 with atomic sync checkpoints. Their paged pull reads and prepares one source +page outside manager write ownership, then queues its atomic data/checkpoint +commit. Network-backed proof checks finish before that queue is acquired; the +commit rejects changed proof records. Payloads are detached during preparation, +and a prepared page can only be consumed once. Foreground operations can run +between pages and during source/proof I/O. Supported reads share up to eight +slots; writers remain exclusive. A waiting background page gets a turn within +eight foreground grants or after one second of waiting at the next release. +This bounds queue preference, not the duration of a provider operation. + +Cancellation before a commit discards that page. Cancellation during a commit +waits for its acknowledgement, reports the committed checkpoint, then stops. +Source I/O also settles before the stopped result: custom providers must supply +their own I/O deadlines. Failures propagate without blind write replay; restart +loads the durable checkpoint. Changing the selected primary fences an older +session before its next write. Concurrent copies of the same source cannot both +commit the same checkpoint. Progress observers receive independent checkpoint +copies and run outside page ownership in paged mode. + +Missing capabilities, remote destinations, self-copies, existing whole-copy +methods and primary reconciliation retain exclusive execution. A failed +capability lookup falls back to serialization. Page ceilings are rough encoded +size hints; the existing negotiated per-record transfer bounds still apply to +large records. One page is in flight, and progress does not accumulate wallet +records or per-record logs. Source pagination retains the existing eventual +replication contract: this API is **not a coherent source snapshot**. Source +snapshot handles and streaming portable archives require their separate +consistency and format contracts. +The existing timestamp boundary is inclusive: an unchanged copy can reread rows +sharing the final timestamp, including an entire same-timestamp import. Those +rows are not rewritten. This protects late same-time arrivals; a coherent source +revision/snapshot is needed to eliminate that boundary traffic safely. Smaller +`maxRoughSize` values (for example 262144) reduce transient authenticated HTTP +memory and event-loop work for constrained devices, at the cost of more pages. + +Every live sync checks declared network chains before registering a destination +user or checkpoint. A missing or unrecognized chain is not inferred. Thrown and +returned provider errors take precedence over `done`, counters and checkpoint +hints; unfinished pages without durable progress stop instead of spinning. +`WERR_NETWORK_CHAIN` and `ProcessSyncChunkResult.error` remain compatible. + +### Canonical proof recovery during actions + +When services are configured, selected-change BEEF is checked before returning +to the signer, including embedded ancestry and prepared artifacts. The actual +send/monitor path checks its rebuilt bundle as well. Valid graphs avoid extra +proof-record reads and copies; checks run with at most eight operations in +flight. Stale roots trigger bounded canonical lookups. Replacement transaction +bytes, Merkle membership, root and active header must agree before use. + +SQLite/MySQL and IndexedDB persist verified corrections with a compare-and-set +against the original proof, and invalidate prepared artifacts in the same +transaction. A concurrent repair is never overwritten. Custom providers may +implement `compareAndSetProvenTxProof`; the default repairs only the outgoing +graph. Failed recovery retains the source records, stops before broadcast, and +returns `WERR_INVALID_MERKLE_ROOT` with its txid, root and height across JSON-RPC. +Failed construction releases its funding reservation through the existing +failed-action cleanup; it does not mark the input spent without evidence. + +Standalone storage construction without configured services retains its offline +contract; its caller must validate before signing or broadcasting. This change +does not audit historical block-hash metadata or repair every stored proof: +BEEF root validity and an operator's historical-store audit remain distinct. + ### UMP account continuity and phone changes Argon2id password derivation uses a proven-ready host backend when one is diff --git a/packages/wallet/wallet-toolbox/benchmarks/create-action-beef.bench.test.ts b/packages/wallet/wallet-toolbox/benchmarks/create-action-beef.bench.test.ts index 39c4eec2d..fe2133d8d 100644 --- a/packages/wallet/wallet-toolbox/benchmarks/create-action-beef.bench.test.ts +++ b/packages/wallet/wallet-toolbox/benchmarks/create-action-beef.bench.test.ts @@ -6,12 +6,7 @@ import { KnexSessionManager } from '../src/storage/remoting/KnexSessionManager' import { StorageServer, WalletStorageServerOptions } from '../src/storage/remoting/StorageServer' import { StorageKnex } from '../src/storage/StorageKnex' import { managedChangeOutputFields } from '../src/storage/methods/managedChange' -import { - TableOutput, - TableOutputBasket, - TableProvenTx, - TableTransaction -} from '../src/storage/schema/tables' +import { TableOutput, TableOutputBasket, TableProvenTx, TableTransaction } from '../src/storage/schema/tables' import { ScriptTemplateBRC29 } from '../src/utility/ScriptTemplateBRC29' import { BdkVerifier } from '@bsv/verifast' @@ -44,17 +39,14 @@ interface BenchmarkContext { ctx: TestWalletNoSetup basket: TableOutputBasket storageEvents: TelemetryEvent[] + canonicalRoots: Set } interface QueryProbe { - stop: () => { queryCount: number, databaseTransactions: number, databaseMs: number } + stop: () => { queryCount: number; databaseTransactions: number; databaseMs: number } } -function makeSourceTransaction ( - index: number, - satoshis: number, - lockingScript: Script -): Transaction { +function makeSourceTransaction(index: number, satoshis: number, lockingScript: Script): Transaction { const transaction = new Transaction() transaction.addInput({ sourceTXID: '00'.repeat(32), @@ -66,14 +58,11 @@ function makeSourceTransaction ( return transaction } -function makeBenchmarkMerklePaths (txids: string[], height: number, seed: number): MerklePath[] { +function makeBenchmarkMerklePaths(txids: string[], height: number, seed: number): MerklePath[] { const sharedLevels = Math.log2(txids.length) if (!Number.isInteger(sharedLevels)) throw new Error('benchmark proof group must be a power of two') const path = Array.from({ length: 24 }, (_, level) => { - const siblingHash = (BigInt(seed + 1) * 10_000n + BigInt(level + 1)) - .toString(16) - .padStart(64, '0') - .slice(-64) + const siblingHash = (BigInt(seed + 1) * 10_000n + BigInt(level + 1)).toString(16).padStart(64, '0').slice(-64) if (level === 0) return txids.map((hash, offset) => ({ offset, hash, txid: true })) if (level < sharedLevels) return [] return [{ offset: 1, hash: siblingHash }] @@ -82,27 +71,41 @@ function makeBenchmarkMerklePaths (txids: string[], height: number, seed: number return txids.map(txid => compound.extract([txid])) } -async function createBenchmarkContext (): Promise { +async function createBenchmarkContext(): Promise { const databaseName = process.env.WALLET_TOOLBOX_BENCH_MYSQL_DATABASE ?? 'createActionBeefBench' - const ctx = process.env.WALLET_TOOLBOX_BENCH_MYSQL === 'true' - ? await _tu.createLegacyWalletMySQLCopy(databaseName, 'legacy') - : await _tu.createLegacyWalletSQLiteCopy(databaseName, 'legacy') + const ctx = + process.env.WALLET_TOOLBOX_BENCH_MYSQL === 'true' + ? await _tu.createLegacyWalletMySQLCopy(databaseName, 'legacy') + : await _tu.createLegacyWalletSQLiteCopy(databaseName, 'legacy') + const canonicalRoots = new Set() + // Synthetic benchmark proofs are valid only at their explicitly registered + // fixture heights. Never query a live chain or approve arbitrary roots. + jest.spyOn(ctx.activeStorage.getServices(), 'getChainTracker').mockResolvedValue({ + isValidRootForHeight: async (root, height) => canonicalRoots.has(`${height}:${root}`), + currentHeight: async () => 1_000_000 + }) const storageEvents: TelemetryEvent[] = [] - Reflect.set(ctx.activeStorage, 'telemetry', new Telemetry({ - sink: { capture: event => storageEvents.push({ ...event }) } - })) + Reflect.set( + ctx.activeStorage, + 'telemetry', + new Telemetry({ + sink: { capture: event => storageEvents.push({ ...event }) } + }) + ) ctx.activeStorage.feeModel = { model: 'sat/kb', value: 100 } - const basket = (await ctx.activeStorage.findOutputBaskets({ - partial: { userId: ctx.userId, name: 'default' } - }))[0] as TableOutputBasket + const basket = ( + await ctx.activeStorage.findOutputBaskets({ + partial: { userId: ctx.userId, name: 'default' } + }) + )[0] as TableOutputBasket await ctx.activeStorage.updateOutputBasket(basket.basketId, { numberOfDesiredUTXOs: 0, minimumDesiredUTXOValue: 1 }) - return { ctx, basket, storageEvents } + return { ctx, basket, storageEvents, canonicalRoots } } -function collectPhaseDurations (events: TelemetryEvent[], firstEvent: number): Record { +function collectPhaseDurations(events: TelemetryEvent[], firstEvent: number): Record { const phaseMs: Record = {} for (const event of events.slice(firstEvent)) { if (event.type !== 'span' || event.durationMs == null) continue @@ -113,7 +116,7 @@ function collectPhaseDurations (events: TelemetryEvent[], firstEvent: number): R return phaseMs } -async function replaceFundingCandidatesWithDistinctProvenSources ( +async function replaceFundingCandidatesWithDistinctProvenSources( setup: BenchmarkContext, candidateCount: number, outputSatoshis: number, @@ -129,10 +132,10 @@ async function replaceFundingCandidatesWithDistinctProvenSources ( const derivationSuffix = `beef-benchmark-${uniqueIndex}` const lockingScript = signableManagedOutputs ? new ScriptTemplateBRC29({ - derivationPrefix, - derivationSuffix, - keyDeriver: ctx.keyDeriver - }).lock(changeKeys.privateKey, changeKeys.publicKey) + derivationPrefix, + derivationSuffix, + keyDeriver: ctx.keyDeriver + }).lock(changeKeys.privateKey, changeKeys.publicKey) : Script.fromHex('51') const source = makeSourceTransaction(uniqueIndex, outputSatoshis, lockingScript) return { @@ -171,6 +174,7 @@ async function replaceFundingCandidatesWithDistinctProvenSources ( for (const preparedSource of prepared) { const { derivationSuffix, lockingScript, source, rawTx, txid } = preparedSource const merklePath = preparedSource.merklePath! + setup.canonicalRoots.add(`${merklePath.blockHeight}:${merklePath.computeRoot(txid)}`) const now = new Date() const proven: TableProvenTx = { created_at: now, @@ -228,24 +232,26 @@ async function replaceFundingCandidatesWithDistinctProvenSources ( return sourceTxids } -function createActionArgs (satoshis = 150_000) { +function createActionArgs(satoshis = 150_000) { return Validation.validateCreateActionArgs({ - outputs: [{ - satoshis, - lockingScript: '51', - outputDescription: 'proof-bearing benchmark output' - }], + outputs: [ + { + satoshis, + lockingScript: '51', + outputDescription: 'proof-bearing benchmark output' + } + ], description: 'createAction proof-bearing fragmented funding benchmark', options: { noSend: true, randomizeOutputs: false, returnTXIDOnly: false } }) } -function startQueryProbe (setup: BenchmarkContext): QueryProbe { +function startQueryProbe(setup: BenchmarkContext): QueryProbe { let queryCount = 0 let databaseTransactions = 0 let databaseMs = 0 - const started = new Map() - const countQuery = (query: { sql?: string, __knexQueryUid?: string }): void => { + const started = new Map() + const countQuery = (query: { sql?: string; __knexQueryUid?: string }): void => { queryCount++ if (/^begin\b/i.test(query.sql?.trim() ?? '')) databaseTransactions++ if (query.__knexQueryUid != null) { @@ -282,17 +288,11 @@ function startQueryProbe (setup: BenchmarkContext): QueryProbe { } } -async function measureStorageCreateAction ( - setup: BenchmarkContext, - candidateCount: number -): Promise { +async function measureStorageCreateAction(setup: BenchmarkContext, candidateCount: number): Promise { const firstEvent = setup.storageEvents.length const probe = startQueryProbe(setup) const start = performance.now() - const result = await setup.ctx.activeStorage.createAction( - { userId: setup.ctx.userId }, - createActionArgs() - ) + const result = await setup.ctx.activeStorage.createAction({ userId: setup.ctx.userId }, createActionArgs()) const elapsedMs = performance.now() - start const query = probe.stop() return { @@ -306,9 +306,7 @@ async function measureStorageCreateAction ( } } -async function createRemoteClient ( - setup: BenchmarkContext -): Promise<{ +async function createRemoteClient(setup: BenchmarkContext): Promise<{ client: TestWalletOnly server: StorageServer verifier: BdkVerifier @@ -356,7 +354,7 @@ async function createRemoteClient ( return { client, server, verifier, verifyDigestBatch, events, serverEvents } } -async function measureRemoteWalletCreateAction ( +async function measureRemoteWalletCreateAction( setup: BenchmarkContext, client: TestWalletOnly, events: TelemetryEvent[], @@ -369,11 +367,13 @@ async function measureRemoteWalletCreateAction ( const probe = startQueryProbe(setup) const start = performance.now() const result = await client.wallet.createAction({ - outputs: [{ - satoshis: 150_000, - lockingScript: '51', - outputDescription: 'proof-bearing benchmark output' - }], + outputs: [ + { + satoshis: 150_000, + lockingScript: '51', + outputDescription: 'proof-bearing benchmark output' + } + ], description: 'authenticated remote proof-bearing fragmented funding benchmark', options: { noSend: true, randomizeOutputs: false, returnTXIDOnly: false } }) @@ -403,12 +403,12 @@ async function measureRemoteWalletCreateAction ( } } -function percentile (values: number[], percentileValue: number): number { +function percentile(values: number[], percentileValue: number): number { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.max(0, Math.ceil(percentileValue * sorted.length) - 1)] } -function summarize (measurements: Measurement[]): MeasurementSummary { +function summarize(measurements: Measurement[]): MeasurementSummary { const elapsed = measurements.map(measurement => measurement.elapsedMs) const database = measurements.map(measurement => measurement.databaseMs) const phaseNames = new Set(measurements.flatMap(measurement => Object.keys(measurement.phaseMs ?? {}))) @@ -473,13 +473,12 @@ describe('createAction proof-bearing fragmented funding benchmark', () => { readEnabled: true, writeEnabled: true }) - jest.spyOn(setup.ctx.activeStorage.getServices(), 'getChainTracker').mockResolvedValue({ - isValidRootForHeight: async () => true - }) - expect(setup.ctx.activeStorage.enqueuePreparedBeef({ - userId: setup.ctx.userId, - rootTxids: warmTxids - })).toBe(true) + expect( + setup.ctx.activeStorage.enqueuePreparedBeef({ + userId: setup.ctx.userId, + rootTxids: warmTxids + }) + ).toBe(true) await setup.ctx.activeStorage.waitForPreparedBeefTasks() const canonicalBuilder = jest.spyOn(setup.ctx.activeStorage, 'getBeefForTransactions') const prepared = await measureStorageCreateAction(setup, warmTxids.length) @@ -512,42 +511,40 @@ describe('createAction proof-bearing fragmented funding benchmark', () => { for (let sample = 0; sample < samples; sample++) { await replaceFundingCandidatesWithDistinctProvenSources(setup, 178, 1_000, sample + 1_000, true) await new Promise(resolve => setTimeout(resolve, 1_250)) - remoteMeasurements.push(await measureRemoteWalletCreateAction( - setup, - remote.client, - remote.events, - remote.serverEvents, - 178 - )) + remoteMeasurements.push( + await measureRemoteWalletCreateAction(setup, remote.client, remote.events, remote.serverEvents, 178) + ) } const typicalRemoteMeasurements: Measurement[] = [] for (let sample = 0; sample < samples; sample++) { await replaceFundingCandidatesWithDistinctProvenSources(setup, 8, 200_000, sample + 2_000, true) await new Promise(resolve => setTimeout(resolve, 1_250)) - typicalRemoteMeasurements.push(await measureRemoteWalletCreateAction( - setup, - remote.client, - remote.events, - remote.serverEvents, - 8 - )) + typicalRemoteMeasurements.push( + await measureRemoteWalletCreateAction(setup, remote.client, remote.events, remote.serverEvents, 8) + ) } const direct = summarize(directMeasurements) const authenticatedRemote = summarize(remoteMeasurements) const typicalAuthenticatedRemote = summarize(typicalRemoteMeasurements) - const digestVerdicts = (await Promise.all( - remote.verifyDigestBatch.mock.results.map(async result => await result.value) - )).flat() - process.stdout.write(`${JSON.stringify({ - direct, - authenticatedRemote, - typicalAuthenticatedRemote, - digestVerificationBatches: remote.verifyDigestBatch.mock.calls.length, - digestVerificationCount: digestVerdicts.length, - digestVerificationFailures: digestVerdicts.filter(valid => !valid).length - }, null, 2)}\n`) + const digestVerdicts = ( + await Promise.all(remote.verifyDigestBatch.mock.results.map(async result => await result.value)) + ).flat() + process.stdout.write( + `${JSON.stringify( + { + direct, + authenticatedRemote, + typicalAuthenticatedRemote, + digestVerificationBatches: remote.verifyDigestBatch.mock.calls.length, + digestVerificationCount: digestVerdicts.length, + digestVerificationFailures: digestVerdicts.filter(valid => !valid).length + }, + null, + 2 + )}\n` + ) expect(direct.p50Ms).toBeLessThan(150) expect(direct.p95Ms).toBeLessThan(500) expect(authenticatedRemote.p50Ms).toBeLessThan(450) @@ -617,17 +614,22 @@ describe('createAction proof-bearing fragmented funding benchmark', () => { resultBeefBytes: result.inputBeef?.length ?? 0, phaseMs }) - await expect(storage.abortAction( - { userId: user.userId }, - { reference: result.reference } - )).resolves.toEqual({ aborted: true }) + await expect(storage.abortAction({ userId: user.userId }, { reference: result.reference })).resolves.toEqual({ + aborted: true + }) } const summary = summarize(measurements) - process.stdout.write(`${JSON.stringify({ - productionShaped: summary, - minSelectedInputCount: Math.min(...measurements.map(measurement => measurement.selectedInputCount)), - minDistinctSourceCount: Math.min(...measurements.map(measurement => measurement.distinctSourceCount)) - }, null, 2)}\n`) + process.stdout.write( + `${JSON.stringify( + { + productionShaped: summary, + minSelectedInputCount: Math.min(...measurements.map(measurement => measurement.selectedInputCount)), + minDistinctSourceCount: Math.min(...measurements.map(measurement => measurement.distinctSourceCount)) + }, + null, + 2 + )}\n` + ) expect(measurements.every(measurement => measurement.candidateCount > 100)).toBe(true) expect(measurements.every(measurement => measurement.selectedInputCount > 100)).toBe(true) expect(measurements.every(measurement => measurement.resultBeefBytes > 0)).toBe(true) diff --git a/packages/wallet/wallet-toolbox/benchmarks/resumable-sync.bench.test.ts b/packages/wallet/wallet-toolbox/benchmarks/resumable-sync.bench.test.ts new file mode 100644 index 000000000..3bc9e2e7d --- /dev/null +++ b/packages/wallet/wallet-toolbox/benchmarks/resumable-sync.bench.test.ts @@ -0,0 +1,248 @@ +import 'fake-indexeddb/auto' +import { once } from 'node:events' +import { randomUUID } from 'node:crypto' +import { performance } from 'node:perf_hooks' +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { StorageIdb } from '../src/storage/StorageIdb' +import { StorageKnex } from '../src/storage/StorageKnex' +import { StorageProvider } from '../src/storage/StorageProvider' +import { WalletStorageManager } from '../src/storage/WalletStorageManager' +import { StorageClient } from '../src/storage/remoting/StorageClient' +import { StorageServer } from '../src/storage/remoting/StorageServer' +import { KnexSessionManager } from '../src/storage/remoting/KnexSessionManager' +import { _tu } from '../test/utils/TestUtilsWalletStorage' +import type { SyncSessionProgress } from '../src/storage/sync/syncSession' +import type { WalletStorageSyncReader } from '../src/sdk/WalletStorage.interfaces' + +import { labels, proofCount, proofBytes, pageBytes, fixedReadMs, seedSyncBenchmark } from './sync-fixture' + +const delay = async (ms: number) => await new Promise(resolve => setTimeout(resolve, ms)) +const percentile = (values: number[], fraction: number) => + [...values].sort((a, b) => a - b)[Math.max(0, Math.ceil(values.length * fraction) - 1)] ?? 0 + +async function store(kind: 'idb' | 'sqlite'): Promise { + const storage = + kind === 'idb' + ? new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + : new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: _tu.createLocalSQLite(':memory:') + }) + if (storage instanceof StorageIdb) storage.dbName = `sync-benchmark-${randomUUID()}` + await storage.migrate('bounded sync benchmark', PrivateKey.fromRandom().toPublicKey().toString()) + await storage.makeAvailable() + return storage +} +async function close(storage: StorageProvider) { + await storage.destroy() + if (storage instanceof StorageIdb) await storage.dropAllData() +} + +describe.each(['sqlite', 'http'] as const)('bounded sync measured on %s', backend => { + test('copies large proofs and same-timestamp tombstones while foreground work progresses', async () => { + const source = await store('sqlite') + const key = PrivateKey.fromRandom() + const identityKey = key.toPublicKey().toString() + process.stdout.write(`seeding ${backend}\n`) + const user = await seedSyncBenchmark(source, identityKey) + process.stdout.write(`seeded ${backend}\n`) + let reader: WalletStorageSyncReader = source + let server: StorageServer | undefined + let wireBytes = 0 + let queries = 0 + if (source instanceof StorageKnex) + source.knex.on('query', () => { + queries++ + }) + if (backend === 'http') { + server = new StorageServer(source, { + port: 0, + wallet: new ProtoWallet(key), + monetize: false, + logRpcRequests: false, + sessionManager: new KnexSessionManager((source as StorageKnex).knex), + adminIdentityKeys: [], + calculateRequestPrice: async () => 0 + }) + server.start() + if (!server.server.listening) await once(server.server, 'listening') + server.server.on('request', (_req, res) => { + const write = res.write.bind(res) + const end = res.end.bind(res) + const size = (chunk: unknown) => + typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk instanceof Uint8Array ? chunk.byteLength : 0 + res.write = ((chunk: unknown, ...args: unknown[]) => { + wireBytes += size(chunk) + return Reflect.apply(write, res, [chunk, ...args]) + }) as typeof res.write + res.end = ((chunk: unknown, ...args: unknown[]) => { + wireBytes += size(chunk) + return Reflect.apply(end, res, [chunk, ...args]) + }) as typeof res.end + }) + const address = server.server.address() + if (address == null || typeof address === 'string') throw new Error('HTTP fixture did not bind') + reader = new StorageClient(new ProtoWallet(key), `http://127.0.0.1:${address.port}`) + } + const getChunk = reader.getSyncChunk.bind(reader) + let pages = 0 + let largestPage = 0 + reader.getSyncChunk = async args => { + expect(args.maxItems).toBeLessThanOrEqual(1000) + expect(args.maxRoughSize).toBeLessThanOrEqual(pageBytes) + await delay(fixedReadMs) + const chunk = await getChunk(args) + pages++ + largestPage = Math.max( + largestPage, + Object.values(chunk).reduce((sum, value) => sum + (Array.isArray(value) ? value.length : 0), 0) + ) + return chunk + } + const reports: Record[] = [] + try { + for (const mode of ['exclusive', 'paged'] as const) { + const destination = await store('sqlite') + if (destination instanceof StorageKnex) + destination.knex.on('query', () => { + queries++ + }) + const capabilities = destination.getCapabilities.bind(destination) + if (mode === 'exclusive') + destination.getCapabilities = async () => ({ ...(await capabilities()), storageAccess: undefined }) + const manager = new WalletStorageManager(identityKey, destination) + await manager.makeAvailable() + const userId = await manager.getUserId() + let active = true + let pending = false + const foreground: number[] = [] + const eventLoopDelay: number[] = [] + let expectedTick = performance.now() + 5 + const progress: SyncSessionProgress[] = [] + let peakRss = process.memoryUsage().rss + const startRss = peakRss + const startCpu = process.cpuUsage() + const beforePages = pages + const beforeQueries = queries + const beforeWire = wireBytes + let foregroundTask: Promise = Promise.resolve() + const timer = setInterval(() => { + const now = performance.now() + eventLoopDelay.push(Math.max(0, now - expectedTick)) + expectedTick = now + 5 + peakRss = Math.max(peakRss, process.memoryUsage().rss) + if (!active || pending) return + pending = true + const started = performance.now() + foregroundTask = manager + .runAsReader( + async () => await destination.findTxLabels({ partial: { userId }, paged: { limit: 20, offset: 0 } }) + ) + .then(async () => { + await manager.runAsWriter(async () => await destination.findOrInsertOutputBasket(userId, 'foreground')) + }) + .then(() => { + foreground.push(performance.now() - started) + }) + .finally(() => { + pending = false + }) + }, 5) + process.stdout.write(`copying ${backend} ${mode}\n`) + const start = performance.now() + try { + const full = await manager.syncFromReaderResumable(identityKey, reader, { + maxRoughSize: pageBytes, + onProgress: event => { + progress.push(event) + if (event.state === 'committed') + process.stdout.write( + `page ${backend} ${mode} ${event.pages}: ${event.inserts} inserted, ${event.commitMs} ms commit\n` + ) + } + }) + const wallMs = performance.now() - start + active = false + clearInterval(timer) + await foregroundTask + const cpu = process.cpuUsage(startCpu) + expect(full).toMatchObject({ status: 'completed', mode }) + expect(await destination.countTxLabels({ partial: { userId } })).toBe(labels) + expect(await destination.countTxLabels({ partial: { userId, isDeleted: true } })).toBe(Math.ceil(labels / 97)) + expect(await destination.countProvenTxs({ partial: {} })).toBe(proofCount) + const report = { + backend, + mode, + labels, + proofCount, + proofBytes, + pageBytes, + fixedReadMs, + wallMs, + cpuMs: (cpu.user + cpu.system) / 1000, + startRss, + peakRss, + rssGrowth: peakRss - startRss, + pages: pages - beforePages, + largestPage, + sqlQueries: queries - beforeQueries, + responseBodyBytes: backend === 'http' ? wireBytes - beforeWire : null, + foreground: { + samples: foreground.length, + p50Ms: percentile(foreground, 0.5), + p95Ms: percentile(foreground, 0.95), + p99Ms: percentile(foreground, 0.99) + }, + eventLoopDelay: { + samples: eventLoopDelay.length, + p95Ms: percentile(eventLoopDelay, 0.95), + p99Ms: percentile(eventLoopDelay, 0.99), + maxMs: Math.max(0, ...eventLoopDelay) + }, + maxQueueMs: Math.max(0, ...progress.map(event => event.queueMs ?? 0)), + maxCommitMs: Math.max(0, ...progress.map(event => event.commitMs ?? 0)) + } + reports.push(report) + if (mode === 'paged') { + expect(foreground.length).toBeGreaterThan(8) + expect(percentile(eventLoopDelay, 0.95)).toBeLessThan(1500) + expect(percentile(foreground, 0.95)).toBeLessThan(1500) + } + expect(wallMs).toBeLessThan(120000) + expect(peakRss - startRss).toBeLessThan(512 * 1024 * 1024) + const quiet = await manager.syncFromReaderResumable(identityKey, reader, { maxRoughSize: pageBytes }) + expect(quiet).toMatchObject({ inserts: 0, updates: 0 }) + // Inclusive legacy timestamps deliberately reread the final boundary. + // An entire imported batch can share that timestamp; preserve late peers. + expect(quiet.pages).toBeLessThanOrEqual(full.pages + 1) + Object.assign(report, { unchangedBoundaryPages: quiet.pages }) + const [changed] = await source.findTxLabels({ partial: { userId: user.userId, label: 'label 1' } }) + await source.updateTxLabel(changed.txLabelId, { isDeleted: !changed.isDeleted, updated_at: new Date() }) + const incremental = await manager.syncFromReaderResumable(identityKey, reader, { maxRoughSize: pageBytes }) + expect(incremental.updates).toBe(1) + expect(incremental.pages).toBeLessThanOrEqual(full.pages + 1) + const settled = await manager.syncFromReaderResumable(identityKey, reader, { maxRoughSize: pageBytes }) + expect(settled).toMatchObject({ inserts: 0, updates: 0 }) + expect(settled.pages).toBeLessThanOrEqual(2) + Object.assign(report, { incrementalPages: incremental.pages, settledUnchangedPages: settled.pages }) + await source.updateTxLabel(changed.txLabelId, { + isDeleted: changed.isDeleted, + updated_at: changed.updated_at + }) + } finally { + active = false + clearInterval(timer) + await foregroundTask + await close(destination) + } + } + const [exclusive, paged] = reports as Array<{ wallMs: number; foreground: { p95Ms: number } }> + expect(paged.foreground.p95Ms).toBeLessThan(exclusive.foreground.p95Ms / 2) + expect(paged.wallMs).toBeLessThan(exclusive.wallMs * 2) + } finally { + process.stdout.write(`${JSON.stringify({ resumableSync: reports }, null, 2)}\n`) + if (server !== undefined) await server.close() + await close(source) + } + }, 300000) +}) diff --git a/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts b/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts new file mode 100644 index 000000000..c425308cb --- /dev/null +++ b/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts @@ -0,0 +1,71 @@ +import { Script, Transaction, MerklePath } from '@bsv/sdk' +import type { StorageProvider } from '../src/storage/StorageProvider' + +export const labels = 10000 +export const proofCount = 64 +export const proofBytes = 32 * 1024 +export const pageBytes = 256 * 1024 +export const fixedReadMs = 15 + +export async function seedSyncBenchmark( + storage: Pick< + StorageProvider, + 'findOrInsertUser' | 'transaction' | 'insertTxLabel' | 'insertProvenTx' | 'insertTransaction' + >, + identityKey: string +) { + const { user } = await storage.findOrInsertUser(identityKey) + const timestamp = new Date(1_700_000_000_000) + await storage.transaction(async trx => { + for (let i = 0; i < labels; i++) + await storage.insertTxLabel( + { + txLabelId: 0, + userId: user.userId, + label: `label ${i}`, + isDeleted: i % 97 === 0, + created_at: timestamp, + updated_at: timestamp + }, + trx + ) + for (let i = 0; i < proofCount; i++) { + const tx = new Transaction() + tx.addOutput({ satoshis: i + 1, lockingScript: Script.fromASM(`OP_FALSE OP_RETURN ${'01'.repeat(proofBytes)}`) }) + const txid = tx.id('hex') + const path = new MerklePath(100, [[{ offset: 0, hash: txid, txid: true }]]) + const provenTxId = await storage.insertProvenTx( + { + provenTxId: 0, + txid, + rawTx: tx.toBinary(), + merklePath: path.toBinary(), + merkleRoot: txid, + height: 100, + index: 0, + blockHash: '01'.repeat(32), + created_at: timestamp, + updated_at: timestamp + }, + trx + ) + await storage.insertTransaction( + { + transactionId: 0, + userId: user.userId, + provenTxId, + txid, + reference: `bounded-sync-${i}`, + status: 'completed', + isOutgoing: false, + satoshis: i + 1, + description: 'large proof sync fixture', + created_at: timestamp, + updated_at: timestamp + }, + trx + ) + } + }) + return user +} diff --git a/packages/wallet/wallet-toolbox/client/README.md b/packages/wallet/wallet-toolbox/client/README.md index 82fc1f70d..bb160642b 100644 --- a/packages/wallet/wallet-toolbox/client/README.md +++ b/packages/wallet/wallet-toolbox/client/README.md @@ -13,6 +13,20 @@ Use this package in: For Node servers, use [`@bsv/wallet-toolbox`](https://www.npmjs.com/package/@bsv/wallet-toolbox). For React Native / mobile, use [`@bsv/wallet-toolbox-mobile`](https://www.npmjs.com/package/@bsv/wallet-toolbox-mobile). +## Resumable synchronization (2.14 candidate) + +`WalletStorageManager.syncFromReaderResumable(identityKey, source, options)` adds +cancellation, durable checkpoints and per-page progress. IndexedDB and Knex local +destinations yield the manager queue during source/proof I/O and between atomic +page commits. Remote destinations retain exclusive execution. Use +`maxRoughSize: 262144` as a measured starting point for constrained clients; +one oversized record may still exceed this rough page target and is subject to +the provider's separate transfer bound. Resume by invoking the API again: the +destination checkpoint is authoritative, including after a lost acknowledgement. +See the [sync contract and next-stage design](../../../../docs/guides/wallet-sync-reliability.md). +This is an eventual replica merge; it does not create a coherent source snapshot +or change the existing archive format. + ## Large wallet records Compatible providers negotiate authenticated, integrity-checked transfers for diff --git a/packages/wallet/wallet-toolbox/client/package.json b/packages/wallet/wallet-toolbox/client/package.json index a12708a9c..b59ecbf84 100644 --- a/packages/wallet/wallet-toolbox/client/package.json +++ b/packages/wallet/wallet-toolbox/client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-client", - "version": "2.13.2", + "version": "2.14.0", "type": "module", "sideEffects": false, "engines": { @@ -56,11 +56,12 @@ "lint": "pnpm --workspace-root exec oxlint packages/wallet/wallet-toolbox/src/index.client.ts packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/index.client.ts packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/index.mobile.ts packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/Api/BlockHeaderApi.ts packages/wallet/wallet-toolbox/client/test packages/wallet/wallet-toolbox/client/tsdown.config.ts packages/wallet/wallet-toolbox/client/vitest.config.ts --deny-warnings", "pack:check": "node ../../../../scripts/check-package-artifact.mjs . --exports Wallet,WalletSigner,WalletStorageManager,StorageClient,StorageIdb,Services,SetupClient,WalletPermissionsManager,WalletSettingsManager,LocalChainTracker,FixedWindowBulkFileDownloadBudget,InlineBulkFileDataValidator,registerArgon2idBackend,unregisterArgon2idBackend,sdk", "test": "vitest run", - "test:browser": "node ../../../../scripts/check-wallet-toolbox-platform.mjs browser", + "test:browser": "node ../../../../scripts/check-wallet-toolbox-platform.mjs browser && node test/sync-browser.mjs", "test:coverage": "vitest run --coverage", "test:watch": "vitest", "typecheck": "tsc --project tsconfig.typecheck.json", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "bench:sync": "pnpm build && node test/sync-browser.mjs" }, "bugs": { "url": "https://github.com/bsv-blockchain/ts-stack/issues" @@ -89,6 +90,7 @@ "tsdown": "0.22.14", "typescript": "npm:@typescript/typescript6@6.0.2", "vite": "8.1.5", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "puppeteer-core": "^25.4.0" } } diff --git a/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs b/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs new file mode 100644 index 000000000..ed4d39cc1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { access } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' +import puppeteer from 'puppeteer-core' + +const candidates = [ + process.env.CHROME_BIN, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', + '/usr/bin/chromium' +].filter(Boolean) +let executablePath +for (const candidate of candidates) { + try { + await access(candidate) + executablePath = candidate + break + } catch { + /* try the next installed browser */ + } +} +assert.ok(executablePath, 'Install Chromium/Chrome or set CHROME_BIN; the native IndexedDB gate cannot be skipped.') +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('./sync-browser.ts', import.meta.url))], + bundle: true, + write: false, + platform: 'browser', + format: 'esm', + target: 'es2022' +}) +const server = createServer((req, res) => { + if (req.url === '/benchmark.js') { + res.setHeader('Content-Type', 'text/javascript') + res.end(bundle.outputFiles[0].contents) + } else { + res.setHeader('Content-Type', 'text/html') + res.end('Native sync acceptance') + } +}) +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) +let browser +try { + browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ['--no-sandbox', '--enable-precise-memory-info'] + }) + const page = await browser.newPage() + page.on('console', message => process.stdout.write(`${message.text()}\n`)) + page.on('pageerror', error => process.stderr.write(`${error}\n`)) + await page.goto(`http://127.0.0.1:${server.address().port}`) + await page.waitForFunction('globalThis.syncBenchmark !== undefined', { timeout: 30000 }) + const reports = await page.evaluate(async () => await globalThis.syncBenchmark) + process.stdout.write(`${JSON.stringify({ nativeSync: reports }, null, 2)}\n`) +} finally { + if (browser !== undefined) await browser.close() + await new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve()))) +} diff --git a/packages/wallet/wallet-toolbox/client/test/sync-browser.ts b/packages/wallet/wallet-toolbox/client/test/sync-browser.ts new file mode 100644 index 000000000..debef8604 --- /dev/null +++ b/packages/wallet/wallet-toolbox/client/test/sync-browser.ts @@ -0,0 +1,187 @@ +import { PrivateKey } from '@bsv/sdk' +import { StorageIdb, StorageProvider, WalletStorageManager, type SyncSessionProgress } from '@bsv/wallet-toolbox-client' +import { + fixedReadMs, + labels, + pageBytes, + proofBytes, + proofCount, + seedSyncBenchmark +} from '../../benchmarks/sync-fixture' + +const percentile = (values: number[], fraction: number) => + [...values].sort((a, b) => a - b)[Math.max(0, Math.ceil(values.length * fraction) - 1)] ?? 0 +const check = (condition: boolean, message: string) => { + if (!condition) throw new Error(message) +} +async function open(): Promise { + const storage = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + storage.dbName = `native-sync-${crypto.randomUUID()}` + await storage.migrate('native browser sync benchmark', PrivateKey.fromRandom().toPublicKey().toString()) + await storage.makeAvailable() + return storage +} +async function close(storage: StorageIdb) { + await storage.destroy() + await storage.dropAllData() +} + +async function benchmark() { + const source = await open() + const identityKey = PrivateKey.fromRandom().toPublicKey().toString() + const user = await seedSyncBenchmark(source, identityKey) + const reports = [] + let reads = 0 + let maxRows = 0 + const read = source.getSyncChunk.bind(source) + source.getSyncChunk = async args => { + check(args.maxItems <= 1000 && args.maxRoughSize <= pageBytes, 'page exceeded configured bounds') + await new Promise(resolve => setTimeout(resolve, fixedReadMs)) + const chunk = await read(args) + reads++ + maxRows = Math.max( + maxRows, + Object.values(chunk).reduce((sum, value) => sum + (Array.isArray(value) ? value.length : 0), 0) + ) + return chunk + } + try { + for (const mode of ['exclusive', 'paged'] as const) { + const destination = await open() + const capabilities = destination.getCapabilities.bind(destination) + if (mode === 'exclusive') + destination.getCapabilities = async () => ({ ...(await capabilities()), storageAccess: undefined }) + const manager = new WalletStorageManager(identityKey, destination) + await manager.makeAvailable() + const userId = await manager.getUserId() + let active = true + let pending = false + let foregroundTask = Promise.resolve() + const foreground: number[] = [] + const eventLoopDelay: number[] = [] + let expectedTick = performance.now() + 5 + const progress: SyncSessionProgress[] = [] + const startReads = reads + const heap = () => + (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory?.usedJSHeapSize ?? 0 + const startHeap = heap() + let peakHeap = startHeap + const timer = setInterval(() => { + const now = performance.now() + eventLoopDelay.push(Math.max(0, now - expectedTick)) + expectedTick = now + 5 + peakHeap = Math.max(peakHeap, heap()) + if (!active || pending) return + pending = true + const started = performance.now() + foregroundTask = manager + .runAsReader( + async () => await destination.findTxLabels({ partial: { userId }, paged: { offset: 0, limit: 20 } }) + ) + .then(async () => { + await manager.runAsWriter(async () => await destination.findOrInsertOutputBasket(userId, 'foreground')) + }) + .then(() => { + foreground.push(performance.now() - started) + }) + .finally(() => { + pending = false + }) + }, 5) + const start = performance.now() + try { + const copied = await manager.syncFromReaderResumable(identityKey, source, { + maxRoughSize: pageBytes, + onProgress: event => progress.push(event) + }) + const wallMs = performance.now() - start + active = false + clearInterval(timer) + await foregroundTask + check(copied.status === 'completed' && copied.mode === mode, 'copy did not complete in expected mode') + check((await destination.countTxLabels({ partial: { userId } })) === labels, 'label count differs') + check( + (await destination.countTxLabels({ partial: { userId, isDeleted: true } })) === Math.ceil(labels / 97), + 'same-timestamp tombstones were lost' + ) + check((await destination.countProvenTxs({ partial: {} })) === proofCount, 'large proofs were lost') + const report = { + backend: 'Chromium IndexedDB', + mode, + labels, + proofCount, + proofBytes, + pageBytes, + fixedReadMs, + wallMs, + pages: reads - startReads, + maxRows, + startHeap, + peakHeap, + foreground: { + samples: foreground.length, + p50Ms: percentile(foreground, 0.5), + p95Ms: percentile(foreground, 0.95), + p99Ms: percentile(foreground, 0.99) + }, + eventLoopDelay: { + samples: eventLoopDelay.length, + p95Ms: percentile(eventLoopDelay, 0.95), + p99Ms: percentile(eventLoopDelay, 0.99), + maxMs: Math.max(0, ...eventLoopDelay) + }, + maxQueueMs: Math.max(0, ...progress.map(event => event.queueMs ?? 0)), + maxCommitMs: Math.max(0, ...progress.map(event => event.commitMs ?? 0)), + maxReadMs: Math.max(0, ...progress.map(event => event.readMs ?? 0)) + } + reports.push(report) + console.log(JSON.stringify(report)) + check(wallMs < 120000, 'full copy exceeded 120 seconds') + check(peakHeap - startHeap < 512 * 1024 * 1024, 'heap growth exceeded 512 MiB') + if (mode === 'paged') { + check(foreground.length > 8, 'background sync starved foreground work') + check(percentile(eventLoopDelay, 0.95) < 1500, 'event loop p95 exceeded 1.5 seconds') + check(percentile(foreground, 0.95) < 1500, 'foreground p95 exceeded 1.5 seconds') + } + const quiet = await manager.syncFromReaderResumable(identityKey, source, { maxRoughSize: pageBytes }) + check( + quiet.inserts === 0 && quiet.updates === 0 && quiet.pages <= copied.pages + 1, + 'unchanged boundary replay mutated rows or exceeded a full pass' + ) + const [changed] = await source.findTxLabels({ partial: { userId: user.userId, label: 'label 1' } }) + await source.updateTxLabel(changed.txLabelId, { isDeleted: true, updated_at: new Date() }) + const incremental = await manager.syncFromReaderResumable(identityKey, source, { maxRoughSize: pageBytes }) + check( + incremental.updates === 1 && incremental.pages <= copied.pages + 1, + 'incremental tombstone did not propagate' + ) + const settled = await manager.syncFromReaderResumable(identityKey, source, { maxRoughSize: pageBytes }) + check( + settled.inserts === 0 && settled.updates === 0 && settled.pages <= 2, + 'settled unchanged copy was not quiet' + ) + Object.assign(report, { + unchangedBoundaryPages: quiet.pages, + incrementalPages: incremental.pages, + settledUnchangedPages: settled.pages + }) + await source.updateTxLabel(changed.txLabelId, { isDeleted: false, updated_at: changed.updated_at }) + } finally { + active = false + clearInterval(timer) + await foregroundTask + await close(destination) + } + } + check( + reports[1].foreground.p95Ms < reports[0].foreground.p95Ms / 2, + 'foreground p95 did not improve by at least 2x' + ) + check(reports[1].wallMs < reports[0].wallMs * 2, 'paged copy doubled total time') + return reports + } finally { + await close(source) + } +} + +Object.assign(globalThis, { syncBenchmark: benchmark() }) diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 2b47db48a..dfd26e796 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -13,6 +13,20 @@ Use this package in: For Node servers, use [`@bsv/wallet-toolbox`](https://www.npmjs.com/package/@bsv/wallet-toolbox). For browsers, use [`@bsv/wallet-toolbox-client`](https://www.npmjs.com/package/@bsv/wallet-toolbox-client). +## Resumable synchronization (2.14 candidate) + +`WalletStorageManager.syncFromReaderResumable(identityKey, source, options)` adds +cancellation, durable checkpoints and per-page progress. IndexedDB and Knex local +destinations yield the manager queue during source/proof I/O and between atomic +page commits. Remote destinations retain exclusive execution. Use +`maxRoughSize: 262144` as a measured starting point for constrained clients; +one oversized record may still exceed this rough page target and is subject to +the provider's separate transfer bound. Resume by invoking the API again: the +destination checkpoint is authoritative, including after a lost acknowledgement. +See the [sync contract and next-stage design](../../../../docs/guides/wallet-sync-reliability.md). +This is an eventual replica merge; it does not create a coherent source snapshot +or change the existing archive format. + ## Large wallet records Compatible providers negotiate authenticated, integrity-checked transfers for diff --git a/packages/wallet/wallet-toolbox/mobile/package.json b/packages/wallet/wallet-toolbox/mobile/package.json index 820554388..0b3efcc67 100644 --- a/packages/wallet/wallet-toolbox/mobile/package.json +++ b/packages/wallet/wallet-toolbox/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-mobile", - "version": "2.13.2", + "version": "2.14.0", "type": "module", "sideEffects": false, "engines": { diff --git a/packages/wallet/wallet-toolbox/mobile/test/payment-transport.test.ts b/packages/wallet/wallet-toolbox/mobile/test/payment-transport.test.ts new file mode 100644 index 000000000..afa700a6b --- /dev/null +++ b/packages/wallet/wallet-toolbox/mobile/test/payment-transport.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + buildMultipartPayment, + preparePaymentTransport, + resolvePaymentTransportLimits +} from '@bsv/sdk/auth/utils/paymentTransport' +import { toUTF8Strict } from '@bsv/sdk/primitives/utils' +import { WalletStorageManager } from '../../src/index.mobile' + +describe('portable sync and payment surface', () => { + it('exposes resumable sync without a Node storage backend', () => { + expect(WalletStorageManager.prototype.syncFromReaderResumable).toBeTypeOf('function') + }) + it('constructs exact multipart bytes without Buffer, FormData or text-codec globals', () => { + const names = ['Buffer', 'FormData', 'TextEncoder', 'TextDecoder'] as const + const descriptors = names.map(name => Object.getOwnPropertyDescriptor(globalThis, name)) + let body!: Uint8Array + let header!: string + try { + for (const name of names) Object.defineProperty(globalThis, name, { configurable: true, value: undefined }) + const result = buildMultipartPayment( + '{"unicode":"雪"}', + { bytes: new Uint8Array([0, 128, 255]), contentType: 'application/octet-stream' }, + 4096, + 'portable-boundary' + ) + body = result.body + header = result.contentType + const selected = preparePaymentTransport( + '{}', + { method: 'GET', headers: {} }, + new Set(['header']), + resolvePaymentTransportLimits() + ) + if (selected.transport !== 'header' || selected.headers['x-bsv-payment'] !== '{}') + throw new Error('Portable header fallback differs') + } finally { + names.forEach((name, index) => { + const descriptor = descriptors[index] + if (descriptor === undefined) Reflect.deleteProperty(globalThis, name) + else Object.defineProperty(globalThis, name, descriptor) + }) + } + expect(header).toBe('multipart/form-data; boundary=portable-boundary') + const expected = new TextEncoder().encode( + '--portable-boundary\r\nContent-Disposition: form-data; name="x-bsv-payment"\r\nContent-Type: application/json\r\n\r\n{"unicode":"雪"}\r\n--portable-boundary\r\nContent-Disposition: form-data; name="body"\r\nContent-Type: application/octet-stream\r\n\r\n' + ) + expect(body.subarray(0, expected.length)).toEqual(expected) + expect(Array.from(body.subarray(expected.length, expected.length + 3))).toEqual([0, 128, 255]) + expect(toUTF8Strict(body.subarray(expected.length + 3))).toBe('\r\n--portable-boundary--\r\n') + }) +}) diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 79bee2844..5c16f7889 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox", - "version": "2.13.2", + "version": "2.14.0", "sideEffects": false, "type": "commonjs", "engines": { @@ -63,7 +63,7 @@ "bench:action-batch": "pnpm build && jest --runInBand --runTestsByPath benchmarks/action-batch.bench.test.ts --testPathIgnorePatterns=man.test.ts", "bench:create-action-funding": "pnpm build && jest --runInBand --runTestsByPath benchmarks/create-action-funding.bench.test.ts --testPathIgnorePatterns=man.test.ts", "bench:create-action-beef": "pnpm build && jest --runInBand --runTestsByPath benchmarks/create-action-beef.bench.test.ts --testPathIgnorePatterns=man.test.ts", - "bench:storage-sync": "pnpm build && jest --runInBand --runTestsByPath benchmarks/storage-sync.bench.test.ts --testPathIgnorePatterns=man.test.ts", + "bench:storage-sync": "pnpm build && jest --runInBand --runTestsByPath benchmarks/storage-sync.bench.test.ts benchmarks/resumable-sync.bench.test.ts --testPathIgnorePatterns=man.test.ts", "format:check": "pnpm --workspace-root exec prettier --check \"packages/wallet/wallet-toolbox/{README.md,jest.config.cjs,package.json,tsconfig*.json}\"", "lint": "oxlint src test benchmarks examples operator --deny-warnings", "lint:ci": "pnpm lint", diff --git a/packages/wallet/wallet-toolbox/src/sdk/ActionBatch.interfaces.ts b/packages/wallet/wallet-toolbox/src/sdk/ActionBatch.interfaces.ts index 650e6f474..cc2a6bf12 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/ActionBatch.interfaces.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/ActionBatch.interfaces.ts @@ -11,6 +11,13 @@ export type ActionBatchPackEncoding = 'identity' | 'gzip' | 'brotli' /** Internal Wallet Toolbox capabilities. These do not extend the BRC-100 wallet interface. */ export interface StorageCapabilities { + /** Opt-in manager scheduling guarantees; absence retains exclusive access. */ + storageAccess?: { + version: 1 + concurrentReads: boolean + /** Page data and its compare-and-set checkpoint commit in one transaction. */ + atomicSyncPages: boolean + } /** Built-in BRC-177 pre-funding, durable expiry, and reclaim support. */ brc177NoSendExpiry?: { version: 1 diff --git a/packages/wallet/wallet-toolbox/src/sdk/WalletErrorFromJson.ts b/packages/wallet/wallet-toolbox/src/sdk/WalletErrorFromJson.ts index 88fac31e9..6d57b4d37 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/WalletErrorFromJson.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/WalletErrorFromJson.ts @@ -8,6 +8,7 @@ import { WERR_INSUFFICIENT_FUNDS, WERR_INTERNAL, WERR_INVALID_OPERATION, + WERR_INVALID_MERKLE_ROOT, WERR_INVALID_PARAMETER, WERR_INVALID_PUBLIC_KEY, WERR_MISSING_PARAMETER, @@ -189,6 +190,18 @@ export function WalletErrorFromJson(json: object): WalletError { case 'WERR_NETWORK_CHAIN': e = new WERR_NETWORK_CHAIN(obj.message) break + case 'WERR_INVALID_MERKLE_ROOT': + if (!Number.isSafeInteger(obj.blockHeight) || obj.blockHeight < 0) { + throw new WERR_INTERNAL('Invalid remote wallet error blockHeight') + } + e = new WERR_INVALID_MERKLE_ROOT( + boundedString(obj.blockHash, 'blockHash', 64), + obj.blockHeight, + boundedString(obj.merkleRoot, 'merkleRoot', 64), + obj.txid === undefined ? undefined : boundedString(obj.txid, 'txid', 64) + ) + if (obj.message !== undefined) e.message = boundedString(obj.message, 'message') + break case 'WERR_UNAUTHORIZED': e = new WERR_UNAUTHORIZED(obj.message) break diff --git a/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts b/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts index 646393729..8677850ca 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts @@ -81,7 +81,7 @@ export interface WalletStorage { getAuth: () => Promise - findOrInsertUser: (identityKey: string) => Promise<{ user: TableUser, isNew: boolean }> + findOrInsertUser: (identityKey: string) => Promise<{ user: TableUser; isNew: boolean }> abortAction: (args: AbortActionArgs) => Promise createAction: (args: Validation.ValidCreateActionArgs) => Promise @@ -158,7 +158,7 @@ export interface WalletStorageSync extends WalletStorageWriter { auth: AuthId, storageIdentityKey: string, storageName: string - ) => Promise<{ syncState: TableSyncState, isNew: boolean }> + ) => Promise<{ syncState: TableSyncState; isNew: boolean }> /** * Updagte the `activeStorage` property of the authenticated user by their `userId`. @@ -184,7 +184,7 @@ export interface WalletStorageWriter extends WalletStorageReader { migrate: (storageName: string, storageIdentityKey: string) => Promise destroy: () => Promise - findOrInsertUser: (identityKey: string) => Promise<{ user: TableUser, isNew: boolean }> + findOrInsertUser: (identityKey: string) => Promise<{ user: TableUser; isNew: boolean }> abortAction: (auth: AuthId, args: AbortActionArgs) => Promise createAction: (auth: AuthId, args: Validation.ValidCreateActionArgs) => Promise @@ -207,10 +207,7 @@ export interface WalletStorageWriter extends WalletStorageReader { putActionBatchBlob: (auth: AuthId, args: PutActionBatchBlobArgs) => Promise putActionBatchPack?: (auth: AuthId, args: PutActionBatchPackArgs) => Promise commitActionBatch: (auth: AuthId, manifest: ActionBatchManifest) => Promise - commitActionBatchByDigest?: ( - auth: AuthId, - args: CommitActionBatchByDigestArgs - ) => Promise + commitActionBatchByDigest?: (auth: AuthId, args: CommitActionBatchByDigestArgs) => Promise abortActionBatch: (auth: AuthId, batchId: string) => Promise internalizeAction: (auth: AuthId, args: InternalizeActionArgs) => Promise @@ -590,7 +587,7 @@ export type SyncProtocolVersion = '0.1.0' export interface SyncCheckpoint { syncStateId: number since?: Date - offsets: Array<{ name: string, offset: number }> + offsets: Array<{ name: string; offset: number }> } export interface RequestSyncChunkArgs { @@ -656,7 +653,7 @@ export interface RequestSyncChunkArgs { * 10 Certificates * 11 CertificateFields */ - offsets: Array<{ name: string, offset: number }> + offsets: Array<{ name: string; offset: number }> } export interface SyncChunkTotals { @@ -715,6 +712,11 @@ export interface ProcessSyncChunkResult { maxUpdated_at: Date | undefined updates: number inserts: number + /** + * Optional failure channel for custom providers; local providers normally throw. + * A failure takes precedence over done, counters and nextCheckpoint. Callers stop + * and resume from the destination's durable checkpoint instead of retrying a write. + */ error?: WalletError } @@ -729,7 +731,7 @@ export interface ReproveHeaderResult { /** * List of proven_txs records that were updated with new proof data. */ - updated: Array<{ was: TableProvenTx, update: Partial, logUpdate: string }> + updated: Array<{ was: TableProvenTx; update: Partial; logUpdate: string }> /** * List of proven_txs records that were checked but currently available proof is unchanged. */ @@ -751,7 +753,7 @@ export interface ReproveProvenResult { /** * Valid if proof data for proven_txs record is available and has changed. */ - updated?: { update: Partial, logUpdate: string } + updated?: { update: Partial; logUpdate: string } /** * True if proof data for proven_txs record was found to be unchanged. */ diff --git a/packages/wallet/wallet-toolbox/src/sdk/__test/WalletError.test.ts b/packages/wallet/wallet-toolbox/src/sdk/__test/WalletError.test.ts index 5035b5c60..ae8c9f25d 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/__test/WalletError.test.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/__test/WalletError.test.ts @@ -9,6 +9,7 @@ import { WERR_INSUFFICIENT_FUNDS, WERR_BROADCAST_UNAVAILABLE, WERR_NETWORK_CHAIN, + WERR_INVALID_MERKLE_ROOT, WERR_INVALID_OPERATION, WERR_MISSING_PARAMETER, WERR_BAD_REQUEST, @@ -436,3 +437,26 @@ describe('WalletError tests', () => { }) }) }) + +test('preserves typed proof failure and recovery context across remote JSON', () => { + const error = new WERR_INVALID_MERKLE_ROOT('11'.repeat(32), 100, '22'.repeat(32), '33'.repeat(32)) + error.message += ' Retry canonical recovery.' + const result = WalletErrorFromJson(JSON.parse(error.toJson())) + expect(result).toBeInstanceOf(WERR_INVALID_MERKLE_ROOT) + expect(result.toJson()).toEqual(error.toJson()) + for (const changes of [ + { blockHeight: -1 }, + { blockHeight: NaN }, + { blockHeight: 1.5 }, + { blockHash: {} }, + { merkleRoot: 'x'.repeat(65) }, + { txid: [] }, + { message: 'x'.repeat(4097) } + ]) { + expect(() => WalletErrorFromJson({ ...JSON.parse(error.toJson()), ...changes })).toThrow( + 'Invalid remote wallet error' + ) + } + const noTxid = new WERR_INVALID_MERKLE_ROOT('11'.repeat(32), 0, '22'.repeat(32)) + expect(WalletErrorFromJson(JSON.parse(noTxid.toJson())).toJson()).toBe(noTxid.toJson()) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts index 5251d16d7..59dd6725f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts @@ -1,3 +1,4 @@ +import { recoveredProofUpdate, sameSyncProof } from './methods/validateSyncProof' import { type ValidListActionsArgs, type ValidListOutputsArgs } from '@bsv/sdk/wallet/validationHelpers' import { deleteDB, IDBPDatabase, IDBPObjectStore, IDBPTransaction, openDB } from 'idb' import { @@ -139,6 +140,10 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider return true } + protected override supportsStorageAccessScheduling(): boolean { + return true + } + protected override supportsNoSendExpiryPersistence(): boolean { return true } @@ -1236,6 +1241,25 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider return await this.updateIdb(id, update, 'provenTxId', 'proven_txs', trx) } + override async compareAndSetProvenTxProof( + expected: TableProvenTx, + replacement: TableProvenTx, + trx?: TrxToken + ): Promise { + const update = recoveredProofUpdate(expected, replacement) + const dbTrx = this.toDbTrx(['proven_txs'], 'readwrite', trx) + const store = dbTrx.objectStore('proven_txs') + try { + const current = await store.get(expected.provenTxId) + if (current == null || !sameSyncProof(current, expected)) return false + await (store.put as (value: TableProvenTx) => Promise)({ ...current, ...update }) + this.isDirty = true + return true + } finally { + if (trx == null) await dbTrx.done + } + } + async updateProvenTxReq(id: number | number[], update: Partial, trx?: TrxToken): Promise { return await this.updateIdb(id, update, 'provenTxReqId', 'proven_tx_reqs', trx) } @@ -2091,10 +2115,21 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider } else { cursor = await store.openCursor() } + let offset = args.paged?.offset ?? 0 + // A full user-label index scan already enforces its entire predicate. Skip + // prior pages in IndexedDB rather than cloning and filtering every prior + // row for every source query. Keep the filtered/since path unchanged. + const indexedOnly = + args.since == null && + Object.entries(args.partial ?? {}).every(([key, value]) => value === undefined || key === 'userId') + if (cursor != null && indexedOnly && Number.isSafeInteger(offset) && offset > 0) { + cursor = await cursor.advance(offset) + offset = 0 + } await scanCursor( cursor, args.since, - args.paged?.offset ?? 0, + offset, args.paged?.limit, r => matchesTxLabelPartial(r, args.partial), filtered diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 669a13e42..1123752d0 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,3 +1,4 @@ +import { recoveredProofUpdate } from './methods/validateSyncProof' import { type ValidListActionsArgs, type ValidListOutputsArgs } from '@bsv/sdk/wallet/validationHelpers' import { ListActionsResult, ListOutputsResult, TelemetrySpan } from '@bsv/sdk' import { @@ -163,6 +164,10 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide protected override supportsActionBatchPersistence(): boolean { return true } + + protected override supportsStorageAccessScheduling(): boolean { + return true + } protected override supportsNoSendExpiryPersistence(): boolean { return true } @@ -1050,6 +1055,27 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update)) } + override async compareAndSetProvenTxProof( + expected: TableProvenTx, + replacement: TableProvenTx, + trx?: TrxToken + ): Promise { + await this.verifyReadyForDatabaseAccess(trx) + const update = recoveredProofUpdate(expected, replacement) + const where = { + provenTxId: expected.provenTxId, + txid: expected.txid, + height: expected.height, + index: expected.index, + merkleRoot: expected.merkleRoot, + blockHash: expected.blockHash, + rawTx: Buffer.from(expected.rawTx), + merklePath: Buffer.from(expected.merklePath) + } + const count = await this.toDb(trx)('proven_txs').where(where).update(this.validatePartialForUpdate(update)) + return count === 1 + } + override async updateSyncState(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('sync_states') @@ -1448,6 +1474,10 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide override async findProvenTxs(args: FindProvenTxsArgs): Promise { const q = this.findProvenTxsQuery(args) + // A transactional exact-proof lookup is a read/modify/write authority + // check. Lock that row until commit so monitor and sync repairs cannot race. + if (args.trx != null && this.dbtype === 'MySQL' && (args.partial.txid != null || args.partial.provenTxId != null)) + q.forUpdate() const r = await q return this.validateEntities(r) } diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index 57d305ea3..d4b6e0871 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -1,3 +1,5 @@ +import { findProofRecords, mapProofWork } from './methods/proofWork' +import { snapshotSyncPage } from './sync/snapshotSyncPage' import { type ValidCreateActionArgs, type ValidListActionsArgs, @@ -97,6 +99,7 @@ import { classifyOutputUtxo, requireConclusiveUtxo } from '../services/classifyO import { processNoSendExpiryLifecycle } from './methods/noSendExpiryLifecycle' import { canonicalizeSyncProofIdentifiers, + markSyncProofReconciled, markSyncProofInsertOnly, sameSyncProof, validateSyncProof @@ -425,6 +428,11 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal async getCapabilities(): Promise { return { + ...(this.supportsStorageAccessScheduling() + ? { + storageAccess: { version: 1 as const, concurrentReads: true, atomicSyncPages: true } + } + : {}), ...(this.supportsNoSendExpiryPersistence() ? { brc177NoSendExpiry: { version: 1 as const } } : {}), ...(this.supportsActionBatchPersistence() ? getActionBatchCapabilities(this.actionBatchMaxReservedOutputs, true) @@ -502,6 +510,11 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal return false } + /** Custom providers retain serialized manager access until explicitly supported. */ + protected supportsStorageAccessScheduling(): boolean { + return false + } + protected supportsActionBatchPersistence(): boolean { return false } @@ -1196,6 +1209,15 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal return await processAction(this, auth, args) } + /** Custom providers may opt into atomic canonical-proof repair; default is no mutation. */ + async compareAndSetProvenTxProof( + _expected: TableProvenTx, + _replacement: TableProvenTx, + _trx?: TrxToken + ): Promise { + return false + } + async attemptToPostReqsToNetwork( reqs: EntityProvenTxReq[], trx?: TrxToken, @@ -1375,7 +1397,8 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal return await this.updateOutput(output.outputId, { basketId: undefined }) } - async processSyncChunk(args: RequestSyncChunkArgs, chunk: SyncChunk): Promise { + private async prepareSyncProofs(chunk: SyncChunk): Promise> { + const expected = new Map() // Canonicalize before text-key lookup in every sync mode. Direct // backup/conflict sync retains its established trust for new proof rows, // but may replace an existing global proof only after active-chain @@ -1383,23 +1406,58 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal const incomingProofs = chunk.provenTxs ?? [] for (const proof of incomingProofs) canonicalizeSyncProofIdentifiers(proof) if (incomingProofs.length > 0) { - const existingProofs = await this.findProvenTxs({ - partial: {}, - txids: [...new Set(incomingProofs.map(proof => proof.txid))] - }) + const existingProofs = await findProofRecords( + this, + incomingProofs.map(proof => proof.txid) + ) const existingByTxid = new Map(existingProofs.map(proof => [proof.txid.toLowerCase(), proof])) - for (const proof of incomingProofs) { + await mapProofWork(incomingProofs, async proof => { const existing = existingByTxid.get(proof.txid) if (existing == null) markSyncProofInsertOnly(proof) - else if (!sameSyncProof(existing, proof)) await validateSyncProof(this, proof) - } + else if (!sameSyncProof(existing, proof)) { + await validateSyncProof(this, proof) + markSyncProofReconciled(proof) + expected.set(proof.txid, existing) + } + }) + } + + return expected + } + + /** Local-only preparation: detach and validate proof I/O before taking manager write ownership. */ + async prepareSyncChunk(args: RequestSyncChunkArgs, chunk: SyncChunk): Promise<() => Promise> { + const snapshot = snapshotSyncPage(args, chunk) + const expected = await this.prepareSyncProofs(snapshot.chunk) + let consumed = false + return async () => { + if (consumed) + throw new WERR_INVALID_OPERATION('Prepared sync page already consumed; resume from its durable checkpoint.') + consumed = true + return await this.commitSyncChunk(snapshot.args, snapshot.chunk, expected) } + } + + async processSyncChunk(args: RequestSyncChunkArgs, chunk: SyncChunk): Promise { + return await this.commitSyncChunk(args, chunk, await this.prepareSyncProofs(chunk)) + } + private async commitSyncChunk( + args: RequestSyncChunkArgs, + chunk: SyncChunk, + expected: Map + ): Promise { // A sync page may contain hundreds of related entities. Keep their lookups, // inserts, ID remapping, and sync-state checkpoint in one transaction. This // avoids a transaction startup/commit for every record (especially costly // in IndexedDB) and makes the page checkpoint atomic with its data changes. return await this.transaction(async trx => { + for (const previous of [...expected.values()].sort((left, right) => left.txid.localeCompare(right.txid))) { + const current = verifyOneOrNone(await this.findProvenTxs({ partial: { txid: previous.txid }, trx })) + if (current == null || !sameSyncProof(current, previous)) { + throw new WERR_INVALID_OPERATION('Proof changed during sync preparation; resume from the durable checkpoint.') + } + } const user = verifyTruthy( verifyOneOrNone(await this.findUsers({ partial: { identityKey: args.identityKey }, trx })) ) diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index c603999a4..c9d4ddbf9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -9,17 +9,18 @@ import { validateRelinquishOutputArgs } from '@bsv/sdk/wallet/validationHelpers' import { SyncPageBudget } from './sync/SyncPageBudget' +import { StorageAccessQueue } from './sync/StorageAccessQueue' +import { runPullSession, type SyncSessionOptions, type SyncSessionResult } from './sync/syncSession' import { validateSyncCheckpoint } from './sync/syncCheckpoint' +import { assertSyncNetwork, assertSyncProgress, throwSyncResultError } from './sync/syncFailure' import { AbortActionArgs, AbortActionResult, Beef, - ChainTracker, InternalizeActionArgs, ListActionsResult, ListCertificatesResult, ListOutputsResult, - MerklePath, RelinquishCertificateArgs, RelinquishOutputArgs } from '@bsv/sdk' @@ -44,7 +45,9 @@ import { TableUser } from '../storage/schema/tables' import { StorageProvider } from './StorageProvider' -import { getCanonicalMerklePath } from '../services/getCanonicalMerklePath' +import { refreshSyncProof } from './methods/refreshSyncProof' +import { recoveredProofUpdate, sameSyncProof } from './methods/validateSyncProof' +import { mapProofWork } from './methods/proofWork' interface PreparedBeefInvalidationExtension { invalidatePreparedBeefs: (trx?: sdk.TrxToken) => Promise @@ -62,6 +65,7 @@ class ManagedStorage { isAvailable: boolean isStorageProvider: boolean settings?: TableSettings + access?: sdk.StorageCapabilities['storageAccess'] user?: TableUser constructor(public storage: sdk.WalletStorageProvider) { @@ -114,6 +118,9 @@ export class WalletStorageManager implements sdk.WalletStorage { * Configured services if any. If valid, shared with stores (which may ignore it). */ _services?: sdk.WalletServices + private availability?: Promise + private generation = 0 + private readonly accessQueue = new StorageAccessQueue() /** * Creates a new WalletStorageManager with the given identityKey and optional active and backup storage providers. @@ -172,12 +179,29 @@ export class WalletStorageManager implements sdk.WalletStorage { */ private async ensureStoreAvailable(store: ManagedStorage): Promise { if (store.isAvailable && store.settings != null && store.user != null) return - store.settings = await store.storage.makeAvailable() + store.settings ??= await store.storage.makeAvailable() const r = await store.storage.findOrInsertUser(this._authId.identityKey) store.user = r.user + // A failed capability lookup changes only scheduling: old or unavailable + // capability endpoints keep the compatible serialized path. + try { + const access = (await store.storage.getCapabilities?.())?.storageAccess + store.access = access?.version === 1 ? access : undefined + } catch { + store.access = undefined + } store.isAvailable = true } + private async preflightManagedNetworks(peer?: TableSettings): Promise { + let reference = peer + for (const store of this._stores) { + store.settings ??= await store.storage.makeAvailable() + if (reference != null) assertSyncNetwork(reference, store.settings) + reference ??= store.settings + } + } + private selectActiveFromStore(store: ManagedStorage, backups: ManagedStorage[]): void { if (this._active == null) { // _stores[0] becomes the default active store. @@ -196,6 +220,17 @@ export class WalletStorageManager implements sdk.WalletStorage { } async makeAvailable(): Promise { + if (this._isAvailable) return this.getActiveSettings() + this.availability ??= this.initializeAvailable() + const pending = this.availability + try { + return await pending + } finally { + if (this.availability === pending) this.availability = undefined + } + } + + private async initializeAvailable(): Promise { if (this._isAvailable) return (this._active as ManagedStorage).settings as TableSettings this._active = undefined @@ -207,6 +242,8 @@ export class WalletStorageManager implements sdk.WalletStorage { } const backups: ManagedStorage[] = [] + // Read all network settings before registering users on any managed store. + await this.preflightManagedNetworks() for (const store of this._stores) { await this.ensureStoreAvailable(store) this.selectActiveFromStore(store, backups) @@ -220,6 +257,7 @@ export class WalletStorageManager implements sdk.WalletStorage { } this._isAvailable = true + this.generation++ this._authId.userId = (this._active as unknown as ManagedStorage).user?.userId this._authId.isActive = this.isActiveEnabled @@ -280,134 +318,53 @@ export class WalletStorageManager implements sdk.WalletStorage { return this._stores.map(b => (b.settings as TableSettings).storageIdentityKey) } - private readonly readerLocks: Array<(value: void | PromiseLike) => void> = [] - private readonly writerLocks: Array<(value: void | PromiseLike) => void> = [] - private readonly syncLocks: Array<(value: void | PromiseLike) => void> = [] - private readonly spLocks: Array<(value: void | PromiseLike) => void> = [] - - private async getActiveLock(lockQueue: Array<(value: void | PromiseLike) => void>): Promise { - if (!this.isAvailable()) await this.makeAvailable() - - let resolveNewLock: () => void = () => {} - const newLock = new Promise(resolve => { - resolveNewLock = resolve - lockQueue.push(resolve) - }) - if (lockQueue.length === 1) { - resolveNewLock() - } - await newLock - } - - private releaseActiveLock(queue: Array<(value: void | PromiseLike) => void>): void { - queue.shift() // Remove the current lock from the queue - if (queue.length > 0) { - queue[0]() + private async withAccess( + operation: (active: sdk.WalletStorageProvider) => Promise, + read = false, + background = false + ): Promise { + await this.makeAvailable() + const concurrent = read && this._active?.access?.concurrentReads === true + const release = await this.accessQueue.acquire( + concurrent ? 'read' : 'exclusive', + background ? 'background' : 'foreground' + ) + // Primary selection may have changed while this request was queued. Never + // apply the former provider's read-sharing promise to its replacement. + if (concurrent && this._active?.access?.concurrentReads !== true) { + release() + return await this.withAccess(operation, read, background) } - } - - private async getActiveForReader(): Promise { - await this.getActiveLock(this.readerLocks) - return this.getActive() - } - - private releaseActiveForReader(): void { - this.releaseActiveLock(this.readerLocks) - } - - private async getActiveForWriter(): Promise { - await this.getActiveLock(this.readerLocks) - await this.getActiveLock(this.writerLocks) - return this.getActive() - } - - private releaseActiveForWriter(): void { - this.releaseActiveLock(this.writerLocks) - this.releaseActiveLock(this.readerLocks) - } - - private async getActiveForSync(): Promise { - await this.getActiveLock(this.readerLocks) - await this.getActiveLock(this.writerLocks) - await this.getActiveLock(this.syncLocks) - return this.getActive() - } - - private releaseActiveForSync(): void { - this.releaseActiveLock(this.syncLocks) - this.releaseActiveLock(this.writerLocks) - this.releaseActiveLock(this.readerLocks) - } - - private async getActiveForStorageProvider(): Promise { - await this.getActiveLock(this.readerLocks) - await this.getActiveLock(this.writerLocks) - await this.getActiveLock(this.syncLocks) - await this.getActiveLock(this.spLocks) - - const active = this.getActive() - // We can finally confirm that active storage is still able to support `StorageProvider` - if (!active.isStorageProvider()) { - throw new WERR_INVALID_OPERATION('Active "WalletStorageProvider" does not support "StorageProvider" interface.') + try { + return await operation(this.getActive()) + } finally { + release() } - // Allow the sync to proceed on the active store. - return active as unknown as StorageProvider - } - - private releaseActiveForStorageProvider(): void { - this.releaseActiveLock(this.spLocks) - this.releaseActiveLock(this.syncLocks) - this.releaseActiveLock(this.writerLocks) - this.releaseActiveLock(this.readerLocks) } async runAsWriter(writer: (active: sdk.WalletStorageWriter) => Promise): Promise { - try { - const active = await this.getActiveForWriter() - const r = await writer(active) - return r - } finally { - this.releaseActiveForWriter() - } + return await this.withAccess(writer) } async runAsReader(reader: (active: sdk.WalletStorageReader) => Promise): Promise { - try { - const active = await this.getActiveForReader() - const r = await reader(active) - return r - } finally { - this.releaseActiveForReader() - } + return await this.withAccess(reader, true) } - /** - * - * @param sync the function to run with sync access lock - * @param activeSync from chained sync functions, active storage already held under sync access lock. - * @returns - */ + /** Borrowed activeSync is the legacy explicit reentrancy contract for an already-held exclusive operation. */ async runAsSync( sync: (active: sdk.WalletStorageSync) => Promise, activeSync?: sdk.WalletStorageSync ): Promise { - try { - const active = activeSync ?? (await this.getActiveForSync()) - const r = await sync(active) - return r - } finally { - if (activeSync == null) this.releaseActiveForSync() - } + return activeSync == null ? await this.withAccess(sync) : await sync(activeSync) } async runAsStorageProvider(sync: (active: StorageProvider) => Promise): Promise { - try { - const active = await this.getActiveForStorageProvider() - const r = await sync(active) - return r - } finally { - this.releaseActiveForStorageProvider() - } + return await this.withAccess(async active => { + if (!active.isStorageProvider()) { + throw new WERR_INVALID_OPERATION('Active "WalletStorageProvider" does not support "StorageProvider" interface.') + } + return await sync(active as unknown as StorageProvider) + }) } /** @@ -442,11 +399,18 @@ export class WalletStorageManager implements sdk.WalletStorage { } async addWalletStorageProvider(provider: sdk.WalletStorageProvider): Promise { - await provider.makeAvailable() - if (this._services != null) provider.setServices(this._services) - this._stores.push(new ManagedStorage(provider)) - this._isAvailable = false - await this.makeAvailable() + const settings = await provider.makeAvailable() + const add = async (): Promise => { + await this.preflightManagedNetworks(settings) + if (this._services != null) provider.setServices(this._services) + const store = new ManagedStorage(provider) + store.settings = settings + this._stores.push(store) + this._isAvailable = false + await this.makeAvailable() + } + if (this._stores.length === 0) await add() + else await this.withAccess(add) } setServices(v: sdk.WalletServices): void { @@ -472,6 +436,7 @@ export class WalletStorageManager implements sdk.WalletStorage { async destroy(): Promise { if (this._stores.length < 1) return return await this.runAsWriter(async _writer => { + this.generation++ for (const store of this._stores) await store.storage.destroy() }) } @@ -691,165 +656,125 @@ export class WalletStorageManager implements sdk.WalletStorage { * @returns */ async reproveHeader(deactivatedHash: string): Promise { - const r: sdk.ReproveHeaderResult = { log: '', updated: [], unchanged: [], unavailable: [] } - - // Lookup all the proven_txs records matching the deactivated headers - let ptxs: TableProvenTx[] = [] - await this.runAsStorageProvider(async sp => { - ptxs = await sp.findProvenTxs({ partial: { blockHash: deactivatedHash } }) - }) - - r.log += ` block ${deactivatedHash} orphaned with ${ptxs.length} impacted transactions\n` - - for (const ptx of ptxs) { - // Loop over proven_txs records matching the deactivated header - const rp = await this.reproveProven(ptx, true) - - r.log += rp.log - if (rp.unavailable) r.unavailable.push(ptx) - if (rp.unchanged) r.unchanged.push(ptx) - if (rp.updated != null) r.updated.push({ was: ptx, update: rp.updated.update, logUpdate: rp.updated.logUpdate }) - } - - // Invalidate as soon as storage contains proof data for the deactivated - // header, even when a replacement proof is not available yet. A prepared - // artifact carrying the old proof must not remain readable during retries. - if (ptxs.length > 0) { - await this.runAsStorageProvider(async sp => { - await sp.transaction(async trx => { - for (const u of r.updated) { - await sp.updateProvenTx(u.was.provenTxId, u.update, trx) - r.log += ` txid ${u.was.txid} proof data updated\n` + u.logUpdate - } - await invalidatePreparedBeefs(sp, trx) - }) - }) - } - - return r + return await this.reproveMatching({ blockHash: deactivatedHash }, `block ${deactivatedHash} orphaned`) } - /** - * For all proven_txs records at the given height currently tied to the given stale merkleRoot, - * attempt to reprove them against the current chain and update proof data if new valid proofs are found. - * - * This is intended for backup auditing of recent heights after the primary reorg event path has run. - */ + /** Audit a stale root against the same canonical evidence as reorg recovery. */ async reproveHeightMerkleRoot(height: number, staleMerkleRoot: string): Promise { - const r: sdk.ReproveHeaderResult = { log: '', updated: [], unchanged: [], unavailable: [] } - - let ptxs: TableProvenTx[] = [] - await this.runAsStorageProvider(async sp => { - ptxs = await sp.findProvenTxs({ partial: { height, merkleRoot: staleMerkleRoot } }) - }) - - r.log += ` height ${height} stale merkleRoot ${staleMerkleRoot} with ${ptxs.length} impacted transactions\n` - - for (const ptx of ptxs) { - const rp = await this.reproveProven(ptx, true) + return await this.reproveMatching( + { height, merkleRoot: staleMerkleRoot }, + `height ${height} stale merkleRoot ${staleMerkleRoot}` + ) + } - r.log += rp.log - if (rp.unavailable) r.unavailable.push(ptx) - if (rp.unchanged) r.unchanged.push(ptx) - if (rp.updated != null) r.updated.push({ was: ptx, update: rp.updated.update, logUpdate: rp.updated.logUpdate }) + private assertProofDestination(storage: StorageProvider, generation: number): void { + if (this.getActive() !== storage || this.generation !== generation) { + throw new WERR_INVALID_OPERATION( + 'Proof destination changed during recovery; retry on the selected storage provider.' + ) } + } - // A matching stale root invalidates prepared proof material whether or not - // this audit can obtain a replacement proof in the same pass. - if (ptxs.length > 0) { - await this.runAsStorageProvider(async sp => { - await sp.transaction(async trx => { - for (const u of r.updated) { - await sp.updateProvenTx(u.was.provenTxId, u.update, trx) - r.log += ` txid ${u.was.txid} proof data updated\n` + u.logUpdate - } - await invalidatePreparedBeefs(sp, trx) - }) - }) + private async prepareReproof( + storage: StorageProvider, + ptx: TableProvenTx + ): Promise<{ + result: sdk.ReproveProvenResult + replacement?: TableProvenTx + }> { + const result: sdk.ReproveProvenResult = { log: '', updated: undefined, unchanged: false, unavailable: false } + try { + const replacement = await refreshSyncProof(storage, ptx) + if (sameSyncProof(ptx, replacement)) { + result.unchanged = true + result.log = ` txid ${ptx.txid} canonical proof unchanged\n` + return { result } + } + result.updated = { + update: recoveredProofUpdate(ptx, replacement), + logUpdate: ` height ${ptx.height} -> ${replacement.height}\n` + } + return { result, replacement } + } catch { + result.unavailable = true + result.log = ` txid ${ptx.txid} canonical proof unavailable\n` + return { result } } - - return r } - /** - * Attempt to reprove the transaction against the current chain, - * If a new valid proof is found and noUpdate is not true, - * update the proven_txs record with new block and merkle proof data. - * If noUpdate is true, the update to be applied is available in the returned result. - * - * @param ptx proven_txs record to reprove - * @param noUpdate - * @returns - */ - private async evaluateNewMerkleLeaf( - ptx: TableProvenTx, - mp: MerklePath, - leaf: { offset: number }, - blockHash: string, - chaintracker: ChainTracker, - r: sdk.ReproveProvenResult, - update: Partial - ): Promise { - if (blockHash === ptx.blockHash) { - r.log += ` txid ${ptx.txid} merkle path update still based on deactivated header ${ptx.blockHash}\n` - r.unchanged = true - return - } - const merkleRoot = mp.computeRoot(ptx.txid) - const isValid = await chaintracker.isValidRootForHeight(merkleRoot, update.height as number) - const heightChange = ptx.height === update.height ? 'unchanged' : `-> ${String(update.height)}` - const logUpdate = ` height ${ptx.height} ${heightChange}\n` - r.log += ` blockHash ${ptx.blockHash} -> ${String(update.blockHash)}\n` - r.log += ` merkleRoot ${ptx.merkleRoot} -> ${String(update.merkleRoot)}\n` - r.log += ` index ${ptx.index} -> ${String(update.index)}\n` - if (isValid === true) { - r.updated = { update, logUpdate } - } else { - r.log += ` txid ${ptx.txid} chaintracker fails to confirm updated merkle path update invalid\n` + logUpdate - r.unavailable = true + private async reproveMatching(partial: Partial, label: string): Promise { + const { storage, generation, ptxs } = await this.runAsStorageProvider(async storage => ({ + storage, + generation: this.generation, + ptxs: await storage.findProvenTxs({ partial }) + })) + // Bound external work and leave foreground storage access available while + // providers fetch proofs/headers. Every replacement is validated before SQL/IDB. + const prepared = await mapProofWork(ptxs, async ptx => await this.prepareReproof(storage, ptx)) + const result: sdk.ReproveHeaderResult = { + log: ` ${label} with ${ptxs.length} impacted transactions\n`, + updated: [], + unchanged: [], + unavailable: [] } + await this.runAsStorageProvider(async active => { + this.assertProofDestination(storage, generation) + if (ptxs.length === 0) return + await active.transaction(async trx => { + for (let index = 0; index < ptxs.length; index++) { + const ptx = ptxs[index] + const { result: proof, replacement } = prepared[index] + result.log += proof.log + if (replacement !== undefined && proof.updated !== undefined) { + if (await active.compareAndSetProvenTxProof(ptx, replacement, trx)) { + result.updated.push({ was: ptx, ...proof.updated }) + result.log += ` txid ${ptx.txid} proof data updated\n` + proof.updated.logUpdate + } else { + result.unavailable.push(ptx) + result.log += ` txid ${ptx.txid} proof changed concurrently or provider cannot commit safely; retry\n` + } + } else if (proof.unchanged) result.unchanged.push(ptx) + else result.unavailable.push(ptx) + } + // Even unavailable replacements invalidate material built from the + // orphaned header. Proof rows and the prepared epoch commit atomically. + await invalidatePreparedBeefs(active, trx) + }) + }) + return result } + /** Validate current-chain evidence; noUpdate returns a proposal without persisting it. */ async reproveProven(ptx: TableProvenTx, noUpdate?: boolean): Promise { - const r: sdk.ReproveProvenResult = { log: '', updated: undefined, unchanged: false, unavailable: false } - const services = this.getServices() - const chaintracker = await services.getChainTracker() - - const mpr = await getCanonicalMerklePath(services, chaintracker, ptx.txid) - if (mpr.merklePath != null && mpr.header != null) { - const mp = mpr.merklePath - const h = mpr.header - const leaf = mp.path[0].find(leaf => leaf.txid === true && leaf.hash === ptx.txid) - if (leaf != null) { - const update: Partial = { - height: mp.blockHeight, - index: leaf.offset, - merklePath: mp.toBinary(), - merkleRoot: h.merkleRoot, - blockHash: h.hash - } - await this.evaluateNewMerkleLeaf(ptx, mp, leaf, h.hash, chaintracker, r, update) - } else { - r.log += ` txid ${ptx.txid} merkle path update doesn't include txid\n` - r.unavailable = true - } - } else { - r.log += ` txid ${ptx.txid} merkle path update unavailable\n` - r.unavailable = true + // The caller retains its input while proof I/O runs outside queue ownership. + ptx = { + ...ptx, + rawTx: ptx.rawTx.slice(), + merklePath: ptx.merklePath.slice(), + created_at: new Date(ptx.created_at), + updated_at: new Date(ptx.updated_at) } - - if (r.updated != null && noUpdate !== true) { - const updatedSnapshot = r.updated - await this.runAsStorageProvider(async sp => { - await sp.transaction(async trx => { - await sp.updateProvenTx(ptx.provenTxId, updatedSnapshot.update, trx) - await invalidatePreparedBeefs(sp, trx) - r.log += ` txid ${ptx.txid} proof data updated\n` + updatedSnapshot.logUpdate - }) + const { storage, generation } = await this.runAsStorageProvider(async storage => ({ + storage, + generation: this.generation + })) + const { result, replacement } = await this.prepareReproof(storage, ptx) + await this.runAsStorageProvider(async active => { + this.assertProofDestination(storage, generation) + if (replacement === undefined || result.updated === undefined || noUpdate === true) return + const updated = result.updated + await active.transaction(async trx => { + if (await active.compareAndSetProvenTxProof(ptx, replacement, trx)) { + await invalidatePreparedBeefs(active, trx) + result.log += ` txid ${ptx.txid} proof data updated\n` + updated.logUpdate + } else { + result.updated = undefined + result.unavailable = true + result.log += ` txid ${ptx.txid} proof changed concurrently or provider cannot commit safely; retry\n` + } }) - } - - return r + }) + return result } private async loadSyncRequest( @@ -883,17 +808,19 @@ export class WalletStorageManager implements sdk.WalletStorage { activeSync?: sdk.WalletStorageSync, log: string = '' ): Promise<{ inserts: number; updates: number; log: string }> { - const auth = await this.getAuth() - if (identityKey !== auth.identityKey) throw new WERR_UNAUTHORIZED() - + if (identityKey !== this._authId.identityKey) throw new WERR_UNAUTHORIZED() const readerSettings = await reader.makeAvailable() + await this.preflightManagedNetworks(readerSettings) + if (activeSync != null) assertSyncNetwork(readerSettings, activeSync.getSettings()) + const auth = await this.getAuth() let inserts = 0 let updates = 0 log = await this.runAsSync(async sync => { const writer = sync - const writerSettings = this.getSettings() + const writerSettings = writer.getSettings() + assertSyncNetwork(readerSettings, writerSettings) log += `syncFromReader from ${readerSettings.storageName} to ${writerSettings.storageName}\n` @@ -910,20 +837,24 @@ export class WalletStorageManager implements sdk.WalletStorage { pageArgs.includeNextCheckpoint = true const startedAt = Date.now() const chunk = await reader.getSyncChunk(pageArgs) + const readMs = Date.now() - startedAt if (chunk.user != null) { // Merging state from a reader cannot update activeStorage chunk.user.activeStorage = ((this._active as ManagedStorage).user as TableUser).activeStorage } const r = await writer.processSyncChunk(pageArgs, chunk) - budget.committed(chunk, Date.now() - startedAt) + throwSyncResultError(r) + budget.committed(chunk, Date.now() - startedAt, readMs) inserts += r.inserts updates += r.updates log += `chunk ${i} inserted ${r.inserts} updated ${r.updates} ${String(r.maxUpdated_at)}\n` if (r.done) break - args = + const next = r.nextCheckpoint == null ? await loadRequest() : { ...args, ...validateSyncCheckpoint(r.nextCheckpoint, args) } + assertSyncProgress(args, next) + args = next } log += `syncFromReader complete: ${inserts} inserts, ${updates} updates\n` return log @@ -932,6 +863,78 @@ export class WalletStorageManager implements sdk.WalletStorage { return { inserts, updates, log } } + /** + * Resumable pull with cancellation and per-page progress. Local providers + * advertising atomic checkpoints yield ownership during source I/O. Older + * and remote destinations keep the safe exclusive path. This is an eventual + * replica merge, not a point-in-time source snapshot or primary activation. + */ + async syncFromReaderResumable( + identityKey: string, + reader: sdk.WalletStorageSyncReader, + options: SyncSessionOptions = {} + ): Promise { + if (identityKey !== this._authId.identityKey) throw new WERR_UNAUTHORIZED() + const readerSettings = await reader.makeAvailable() + await this.preflightManagedNetworks(readerSettings) + const auth = { ...(await this.getAuth()) } + const writer = this.getActive() + const writerSettings = writer.getSettings() + assertSyncNetwork(readerSettings, writerSettings) + const generation = this.generation + const activeStorage = this.getActiveUser().activeStorage + const atomicCheckpoint = this._active?.access?.atomicSyncPages === true + const paged = + writer.isStorageProvider() && + atomicCheckpoint && + reader !== writer && + typeof (writer as Partial).prepareSyncChunk === 'function' + const assertCurrent = (): void => { + if (generation !== this.generation || writer !== this.getActive()) { + throw new WERR_INVALID_OPERATION( + 'Sync destination generation changed; resume on the selected storage provider.' + ) + } + } + const run = async (commit: (operation: () => Promise) => Promise): Promise => + await runPullSession( + { + reader, + writer, + activeStorage, + atomicCheckpoint, + mode: paged ? 'paged' : 'exclusive', + loadRequest: async () => + await this.loadSyncRequest(auth, writer, readerSettings, writerSettings.storageIdentityKey), + prepare: paged + ? async (args, chunk) => await (writer as StorageProvider).prepareSyncChunk(args, chunk) + : undefined, + commit + }, + options + ) + if (paged) { + return await run( + async operation => + await this.withAccess( + async () => { + assertCurrent() + return await operation() + }, + false, + true + ) + ) + } + return await this.runAsSync(async () => { + assertCurrent() + return await run(async operation => { + assertCurrent() + return await operation() + }) + }) + } + async syncToWriter( auth: sdk.AuthId, writer: sdk.WalletStorageProvider, @@ -942,6 +945,8 @@ export class WalletStorageManager implements sdk.WalletStorage { progLog ||= s => s const writerSettings = await writer.makeAvailable() + await this.preflightManagedNetworks(writerSettings) + if (activeSync != null) assertSyncNetwork(activeSync.getSettings(), writerSettings) let inserts = 0 let updates = 0 @@ -949,6 +954,7 @@ export class WalletStorageManager implements sdk.WalletStorage { log = await this.runAsSync(async sync => { const reader = sync const readerSettings = reader.getSettings() + assertSyncNetwork(readerSettings, writerSettings) log += progLog(`syncToWriter from ${readerSettings.storageName} to ${writerSettings.storageName}\n`) @@ -965,17 +971,21 @@ export class WalletStorageManager implements sdk.WalletStorage { pageArgs.includeNextCheckpoint = true const startedAt = Date.now() const chunk = await reader.getSyncChunk(pageArgs) + const readMs = Date.now() - startedAt log += EntitySyncState.syncChunkSummary(chunk) const r = await writer.processSyncChunk(pageArgs, chunk) - budget.committed(chunk, Date.now() - startedAt) + throwSyncResultError(r) + budget.committed(chunk, Date.now() - startedAt, readMs) inserts += r.inserts updates += r.updates log += progLog(`chunk ${i} inserted ${r.inserts} updated ${r.updates} ${String(r.maxUpdated_at)}\n`) if (r.done) break - args = + const next = r.nextCheckpoint == null ? await loadRequest() : { ...args, ...validateSyncCheckpoint(r.nextCheckpoint, args) } + assertSyncProgress(args, next) + args = next } log += progLog(`syncToWriter complete: ${inserts} inserts, ${updates} updates\n`) return log @@ -1032,6 +1042,7 @@ export class WalletStorageManager implements sdk.WalletStorage { log += progLog('\n') log += await this.runAsSync(async _sync => { + this.generation++ let log = '' if ((this._conflictingActives as ManagedStorage[]).length > 0) { diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts index b637b04cb..430eef124 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts @@ -3,9 +3,35 @@ import { wait } from '../..' import { _tu, TestWalletNoSetup } from '../../../test/utils/TestUtilsWalletStorage' import { StorageProvider } from '../StorageProvider' import { StorageReaderWriter } from '../StorageReaderWriter' +import { TableProvenTx } from '../schema/tables' +import { toBinaryBaseBlockHeader } from '../../services/Services' +import { doubleSha256BE } from '../../utility/utilityHelpers' +import { asString } from '../../utility/utilityHelpers.noBuffer' import * as dotenv from 'dotenv' +function canonicalReproof(ctx: TestWalletNoSetup, ptx: TableProvenTx) { + const merklePath = new bsv.MerklePath(ptx.height + 1, [[{ offset: 0, hash: ptx.txid, txid: true }]]) + const header = toBinaryBaseBlockHeader({ + version: 1, + previousHash: '0'.repeat(64), + merkleRoot: merklePath.computeRoot(ptx.txid), + time: 0, + bits: 0, + nonce: 0 + }) + const services = ctx.storage.getServices() + const isValidRootForHeight = jest.fn(async () => true) + jest.spyOn(services, 'getChainTracker').mockResolvedValue({ isValidRootForHeight } as bsv.ChainTracker) + jest.spyOn(services, 'getHeaderForHeight').mockResolvedValue(header) + const lookup = jest.spyOn(services, 'getValidatedMerklePath').mockImplementation(async (_txid, validate) => { + const result = { name: 'canonical reproof fixture', merklePath } + await validate(result) + return result + }) + return { lookup, isValidRootForHeight, height: merklePath.blockHeight, blockHash: asString(doubleSha256BE(header)) } +} + dotenv.config() describe('WalletStorageManager tests', () => { jest.setTimeout(99999999) @@ -477,16 +503,11 @@ describe('WalletStorageManager tests', () => { const [ptx] = await ctx.activeStorage.findProvenTxs({ partial: {} }) expect(ptx).toBeTruthy() const epoch = await ctx.activeStorage.readPreparedBeefProofEpoch() - const reprove = jest.spyOn(ctx.storage, 'reproveProven').mockResolvedValue({ - log: '', - updated: { update: { height: ptx.height }, logUpdate: '' }, - unchanged: false, - unavailable: false - }) + const { lookup } = canonicalReproof(ctx, ptx) const result = await ctx.storage.reproveHeader(ptx.blockHash) - expect(reprove).toHaveBeenCalled() + expect(lookup).toHaveBeenCalled() expect(result.updated.length).toBeGreaterThan(0) await expect(ctx.activeStorage.readPreparedBeefProofEpoch()).resolves.toBe(epoch + 1) } finally { @@ -538,12 +559,8 @@ describe('WalletStorageManager tests', () => { const [ptx] = await ctx.activeStorage.findProvenTxs({ partial: {} }) expect(ptx).toBeTruthy() const epoch = await ctx.activeStorage.readPreparedBeefProofEpoch() - jest.spyOn(ctx.storage, 'reproveProven').mockResolvedValue({ - log: '', - updated: undefined, - unchanged: false, - unavailable: true - }) + const { lookup } = canonicalReproof(ctx, ptx) + lookup.mockRejectedValue(new Error('proof unavailable')) const result = await ctx.storage.reproveHeader(ptx.blockHash) @@ -561,16 +578,11 @@ describe('WalletStorageManager tests', () => { const [ptx] = await ctx.activeStorage.findProvenTxs({ partial: {} }) expect(ptx).toBeTruthy() const epoch = await ctx.activeStorage.readPreparedBeefProofEpoch() - const reprove = jest.spyOn(ctx.storage, 'reproveProven').mockResolvedValue({ - log: '', - updated: { update: { height: ptx.height }, logUpdate: 'height reproof\n' }, - unchanged: false, - unavailable: false - }) + const { lookup } = canonicalReproof(ctx, ptx) const result = await ctx.storage.reproveHeightMerkleRoot(ptx.height, ptx.merkleRoot) - expect(reprove).toHaveBeenCalledWith(ptx, true) + expect(lookup).toHaveBeenCalledWith(ptx.txid, expect.any(Function)) expect(result.updated).toHaveLength(1) expect(result.log).toContain('proof data updated') await expect(ctx.activeStorage.readPreparedBeefProofEpoch()).resolves.toBe(epoch + 1) @@ -591,36 +603,8 @@ describe('WalletStorageManager tests', () => { const [ptx] = await ctx.activeStorage.findProvenTxs({ partial: {} }) expect(ptx).toBeTruthy() const epoch = await ctx.activeStorage.readPreparedBeefProofEpoch() - const replacementHash = ptx.blockHash === 'f'.repeat(64) ? 'e'.repeat(64) : 'f'.repeat(64) - const replacementHeight = ptx.height + 1 - const merklePath = new bsv.MerklePath(replacementHeight, [ - [ - { - offset: 0, - hash: ptx.txid, - txid: true - } - ] - ]) - const services = ctx.storage.getServices() - const isValidRootForHeight = jest.fn(async () => 'true' as unknown as boolean) - jest.spyOn(services, 'getChainTracker').mockResolvedValue({ - isValidRootForHeight - } as bsv.ChainTracker) - jest.spyOn(services, 'getMerklePath').mockResolvedValue({ - name: 'prepared BEEF reproof test', - merklePath, - header: { - version: 1, - previousHash: '0'.repeat(64), - merkleRoot: merklePath.computeRoot(ptx.txid), - time: 0, - bits: 0, - nonce: 0, - height: replacementHeight, - hash: replacementHash - } - }) + const { height: replacementHeight, blockHash: replacementHash, isValidRootForHeight } = canonicalReproof(ctx, ptx) + isValidRootForHeight.mockResolvedValue('true' as unknown as boolean) const rejected = await ctx.storage.reproveProven(ptx) expect(rejected).toMatchObject({ unavailable: true, updated: undefined }) diff --git a/packages/wallet/wallet-toolbox/src/storage/index.all.ts b/packages/wallet/wallet-toolbox/src/storage/index.all.ts index be16c7f3b..2acc23c06 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.all.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.all.ts @@ -1,6 +1,7 @@ export * from './WalletStorageManager' export * from './StorageProvider' export * from './StorageSyncReader' +export type { SyncSessionOptions, SyncSessionProgress, SyncSessionResult } from './sync/syncSession' export * from './remoting/StorageClient' export * from './remoting/StorageServer' export * from './remoting/KnexSessionManager' diff --git a/packages/wallet/wallet-toolbox/src/storage/index.client.ts b/packages/wallet/wallet-toolbox/src/storage/index.client.ts index 89419312b..799fac3b6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.client.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.client.ts @@ -2,6 +2,7 @@ export * from './WalletStorageManager' export * from './StorageIdb' export * from './StorageProvider' export * from './StorageSyncReader' +export type { SyncSessionOptions, SyncSessionProgress, SyncSessionResult } from './sync/syncSession' export * from './schema/tables/index' export * from './schema/entities/index' export * from './remoting/StorageClient' diff --git a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts index 0a709e46a..7cfc44136 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts @@ -1,6 +1,7 @@ export * from './WalletStorageManager' export * from './StorageProvider' export * from './StorageSyncReader' +export type { SyncSessionOptions, SyncSessionProgress, SyncSessionResult } from './sync/syncSession' export * from './schema/tables/index' export * from './schema/entities/index' export * from './remoting/StorageMobile' diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/attemptToPostReqsToNetwork.ts b/packages/wallet/wallet-toolbox/src/storage/methods/attemptToPostReqsToNetwork.ts index 1ea521444..bd968f73c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/attemptToPostReqsToNetwork.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/attemptToPostReqsToNetwork.ts @@ -1,4 +1,5 @@ import { Beef, Transaction, WalletLoggerInterface } from '@bsv/sdk' +import { repairBeefProofs } from './repairBeefProofs' import { StorageProvider } from '../StorageProvider' import { EntityProvenTxReq } from '../schema/entities' import type * as sdk from '../../sdk' @@ -28,6 +29,9 @@ export async function attemptToPostReqsToNetwork( const services = storage.getServices() + // Rebuilding stored input BEEF may reintroduce an orphaned ancestor even + // after an earlier foreground check. This is also the monitor's send path. + r.beef = await repairBeefProofs(storage, r.beef, trx) const pbrs = await services.postBeef(r.beef, txids, logger) // post beef results (pbrs) is an array by service provider diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts index 2725c5a98..7b5e6e0a7 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts @@ -5,6 +5,7 @@ import { validateSatoshis } from '@bsv/sdk/wallet/validationHelpers' import { Beef, OriginatorDomainNameStringUnder250Bytes, Random, Script, TelemetrySpan } from '@bsv/sdk' +import { repairBeefProofs } from './repairBeefProofs' import { toBase64 } from '@bsv/sdk/primitives/utils' import { generateChangeSdk, @@ -2003,6 +2004,11 @@ async function mergeAllocatedChangeBeefs( }) } ) + // Standalone offline storage historically permits construction without + // services. Configured wallets validate even prepared/embedded ancestry + // before returning proofs to their signer; offline callers retain their + // existing responsibility to validate before signing or broadcasting. + if (storage._services != null) beef = await repairBeefProofs(storage, beef) const inputBeef = await traceStorageStep( storage, 'wallet.storage.create_action.beef_trim_serialize', diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts index 489646527..333b53db3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts @@ -1,3 +1,4 @@ +import { repairBeefProofs } from './repairBeefProofs' // eslint-disable-next-line @typescript-eslint/no-unused-vars import { Beef, @@ -45,7 +46,7 @@ interface PreparedBeefWriterExtension { enqueuePreparedBeef: (preparation: PreparedBeefPreparation) => boolean } -function normalizeProcessActionArgs (args: StorageProcessActionArgs): StorageProcessActionArgs { +function normalizeProcessActionArgs(args: StorageProcessActionArgs): StorageProcessActionArgs { for (const property of ['isNewTx', 'isSendWith', 'isNoSend', 'isDelayed'] as const) { if (typeof args[property] !== 'boolean') { throw new WERR_INVALID_PARAMETER(property, 'a boolean') @@ -67,7 +68,7 @@ function normalizeProcessActionArgs (args: StorageProcessActionArgs): StoragePro return { ...args, sendWith } } -export async function processAction ( +export async function processAction( storage: StorageProvider, auth: AuthId, args: StorageProcessActionArgs @@ -94,7 +95,7 @@ export async function processAction ( ) } -async function processActionCore ( +async function processActionCore( storage: StorageProvider, auth: AuthId, args: StorageProcessActionArgs, @@ -133,7 +134,9 @@ async function processActionCore ( typeof preparedBeef.preparedBeefWritesEnabled === 'function' && preparedBeef.preparedBeefWritesEnabled.call(storage) && vargs.outputOutputs.some(isManagedChangeOutput) - ) { prepareRootTxid = req.txid } + ) { + prepareRootTxid = req.txid + } // Add the new txid to sendWith unless there are no others to send and the noSend option is set. if (args.isNoSend && !args.isSendWith) { logger?.log(`noSend txid ${req.txid}`) @@ -169,7 +172,7 @@ async function processActionCore ( return r } -async function traceProcessStep ( +async function traceProcessStep( storage: StorageProvider, name: string, parent: TelemetrySpan | undefined, @@ -225,7 +228,7 @@ export interface PostBeefResultForTxidApi { * @param isDelayed * @param r Optional. Ignores txids and allows ProvenTxReqs and merged beef to be passed in. */ -function classifyReqDetails ( +function classifyReqDetails( details: GetReqsAndBeefDetail[], swr: SendWithResult[], readyToSendReqs: EntityProvenTxReq[] @@ -247,22 +250,23 @@ function classifyReqDetails ( return complete } -async function verifyMergedBeef ( +async function verifyMergedBeef( storage: StorageProvider, r: GetReqsAndBeefResult, readyToSendReqs: EntityProvenTxReq[], logger?: WalletLoggerInterface ): Promise { if (readyToSendReqs.length === 0 || r.verified === true) return + r.beef = await repairBeefProofs(storage, r.beef) const beefIsValid = await r.beef.verify(await storage.getServices().getChainTracker()) if (!beefIsValid) { - logger?.error(`VERIFY FALSE BEEF: ${r.beef.toLogString()}`) + logger?.error('Merged transaction proof graph failed validation; no broadcast attempted.') throw new WERR_INTERNAL('merged Beef failed validation.') } logger?.log('beef is valid') } -async function getReqDetailsForDelayedShare (storage: StorageProvider, txids: string[]): Promise { +async function getReqDetailsForDelayedShare(storage: StorageProvider, txids: string[]): Promise { const r: GetReqsAndBeefResult = { beef: new Beef(), details: [] @@ -298,14 +302,14 @@ async function getReqDetailsForDelayedShare (storage: StorageProvider, txids: st return r } -export async function shareReqsWithWorld ( +export async function shareReqsWithWorld( storage: StorageProvider, userId: number, txids: string[], isDelayed: boolean, r?: GetReqsAndBeefResult, logger?: WalletLoggerInterface -): Promise<{ swr: SendWithResult[], ndr: ReviewActionResult[] | undefined }> { +): Promise<{ swr: SendWithResult[]; ndr: ReviewActionResult[] | undefined }> { txids = normalizePostTxids(txids, 'txids', true) const swr: SendWithResult[] = [] const ndr: ReviewActionResult[] | undefined = undefined @@ -316,7 +320,11 @@ export async function shareReqsWithWorld ( ? await getReqDetailsForDelayedShare(storage, txids) : await storage.getReqsAndBeefToShareWithWorld(txids, []) - normalizePostTxids(r.details.map(detail => detail.txid), 'details', true) + normalizePostTxids( + r.details.map(detail => detail.txid), + 'details', + true + ) const readyToSendReqs: EntityProvenTxReq[] = [] if (!classifyReqDetails(r.details, swr, readyToSendReqs)) return { swr, ndr } @@ -356,12 +364,14 @@ interface ReqTxStatus { tx: TransactionStatus } -function determineReqTxStatus (params: Pick): { +function determineReqTxStatus(params: Pick): { status: ReqTxStatus postStatus: ReqTxStatus | undefined } { if (params.isNoSend && !params.isSendWith) return { status: { req: 'nosend', tx: 'nosend' }, postStatus: undefined } - if (!params.isNoSend && params.isDelayed) { return { status: { req: 'unsent', tx: 'unprocessed' }, postStatus: undefined } } + if (!params.isNoSend && params.isDelayed) { + return { status: { req: 'unsent', tx: 'unprocessed' }, postStatus: undefined } + } if (!params.isNoSend && !params.isDelayed) { return { status: { req: 'unprocessed', tx: 'unprocessed' }, @@ -371,7 +381,7 @@ function determineReqTxStatus (params: Pick }> + outputUpdates: Array<{ id: number; update: Partial }> transactionUpdate: Partial postStatus?: ReqTxStatus } -function parseProcessActionTransaction (params: StorageProcessActionArgs): { +function parseProcessActionTransaction(params: StorageProcessActionArgs): { reference: string txid: string rawTx: number[] @@ -449,7 +459,7 @@ function parseProcessActionTransaction (params: StorageProcessActionArgs): { return { reference, txid, rawTx, tx } } -async function validateNoSendExpiryRelease ( +async function validateNoSendExpiryRelease( storage: StorageProvider, auth: AuthId, params: StorageProcessActionArgs, @@ -471,7 +481,7 @@ async function validateNoSendExpiryRelease ( if (expired) throw new WERR_INVALID_OPERATION('BRC-177 protected action has expired') } -function validatePlannedTransaction (transaction: TableTransaction): void { +function validatePlannedTransaction(transaction: TableTransaction): void { if (!transaction.isOutgoing) throw new WERR_INVALID_OPERATION('isOutgoing is not true') if (transaction.inputBEEF == null) throw new WERR_INVALID_OPERATION() if (transaction.status !== 'unsigned' && transaction.status !== 'unprocessed') { @@ -479,7 +489,7 @@ function validatePlannedTransaction (transaction: TableTransaction): void { } } -function validateCommissionOutput ( +function validateCommissionOutput( storage: StorageProvider, tx: BsvTransaction, commissionRows: TableCommission[] @@ -496,7 +506,7 @@ function validateCommissionOutput ( } } -async function validateCommitNewTxToStorageArgs ( +async function validateCommitNewTxToStorageArgs( storage: StorageProvider, auth: AuthId, params: StorageProcessActionArgs @@ -590,7 +600,7 @@ export interface CommitNewTxResults { log?: string } -async function commitNewTxToStorage ( +async function commitNewTxToStorage( storage: StorageProvider, userId: number, vargs: ValidCommitNewTxToStorageArgs diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.test.ts b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.test.ts new file mode 100644 index 000000000..20657cb32 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.test.ts @@ -0,0 +1,59 @@ +import type { StorageProvider } from '../StorageProvider' +import { findProofRecords, mapProofWork } from './proofWork' + +test('bounds proof concurrency and drains already-started I/O before rejecting', async () => { + let release!: () => void + const gate = new Promise(resolve => { + release = resolve + }) + let active = 0 + let started = 0 + let peak = 0 + let settled = false + const failure = new Error('proof lookup failed') + const pending = mapProofWork( + Array.from({ length: 20 }, (_, index) => index), + async index => { + active++ + started++ + peak = Math.max(peak, active) + try { + if (index === 0) throw failure + await gate + return index + } finally { + active-- + } + } + ) + const rejected = expect(pending).rejects.toBe(failure) + void pending.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await Promise.resolve() + await Promise.resolve() + expect(started).toBe(8) + expect(peak).toBeLessThanOrEqual(8) + expect(settled).toBe(false) + release() + await rejected + expect(active).toBe(0) + expect(started).toBe(8) +}) + +test('deduplicates and bounds large proof lookups before reaching SQL bindings', async () => { + const findProvenTxs = jest.fn(async () => []) + const txids = Array.from({ length: 1201 }, (_, i) => i.toString(16).padStart(64, '0')) + await expect( + findProofRecords({ findProvenTxs } as unknown as StorageProvider, [...txids, ...txids]) + ).resolves.toEqual([]) + expect(findProvenTxs).toHaveBeenCalledTimes(5) + const requests = findProvenTxs.mock.calls as unknown as [{ txids: string[] }][] + expect(requests.every(([args]) => args.txids.length <= 250)).toBe(true) + expect(requests.flatMap(([args]) => args.txids)).toEqual(txids) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts new file mode 100644 index 000000000..500794d09 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts @@ -0,0 +1,38 @@ +import type { StorageProvider } from '../StorageProvider' +import type { TrxToken } from '../../sdk/WalletStorage.interfaces' +import type { TableProvenTx } from '../schema/tables' + +/** Bound SQL parameters even for legacy callers supplying a large proof page. */ +export async function findProofRecords( + storage: StorageProvider, + txids: string[], + trx?: TrxToken +): Promise { + const unique = [...new Set(txids)] + const records: TableProvenTx[] = [] + for (let offset = 0; offset < unique.length; offset += 250) { + records.push(...(await storage.findProvenTxs({ partial: {}, txids: unique.slice(offset, offset + 250), trx }))) + } + return records +} + +/** All started work drains before a failure escapes; at most eight checks run. */ +export async function mapProofWork(items: T[], work: (item: T) => Promise): Promise { + const results: R[] = [] + let next = 0 + let failed = false + const workers = Array.from({ length: Math.min(items.length, 8) }, async () => { + while (!failed && next < items.length) { + const index = next++ + try { + results[index] = await work(items[index]) + } catch (error) { + failed = true + throw error + } + } + }) + const settled = await Promise.allSettled(workers) + for (const result of settled) if (result.status === 'rejected') throw result.reason + return results +} diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/refreshSyncProof.ts b/packages/wallet/wallet-toolbox/src/storage/methods/refreshSyncProof.ts new file mode 100644 index 000000000..59dc03966 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/methods/refreshSyncProof.ts @@ -0,0 +1,49 @@ +import { WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' +import type { GetMerklePathResult } from '../../sdk/WalletServices.interfaces' +import { doubleSha256BE } from '../../utility/utilityHelpers' +import { asString } from '../../utility/utilityHelpers.noBuffer' +import { validateSyncProof, markSyncProofReconciled } from './validateSyncProof' +import type { SyncProofValidationStorage } from './validateSyncProof' +import type { TableProvenTx } from '../schema/tables/TableProvenTx' + +/** Obtain replacement metadata; never alter transaction bytes, IDs or timestamps. */ +export async function refreshSyncProof( + storage: SyncProofValidationStorage, + candidate: TableProvenTx +): Promise { + const services = storage.getServices() + let current: TableProvenTx | undefined + const validate = async ({ merklePath }: GetMerklePathResult): Promise => { + if (merklePath == null) throw new Error('Current proof unavailable') + const header = await services.getHeaderForHeight(merklePath.blockHeight) + const replacement = { + ...candidate, + height: merklePath.blockHeight, + index: merklePath.path[0]?.find(leaf => leaf.txid === true && leaf.hash === candidate.txid)?.offset ?? -1, + merklePath: merklePath.toBinary(), + merkleRoot: merklePath.computeRoot(candidate.txid), + blockHash: asString(doubleSha256BE(header)) + } + // Recheck raw bytes, membership, root and active header before authorizing + // any global proof replacement. A provider's success response is insufficient. + await validateSyncProof(storage, replacement) + markSyncProofReconciled(replacement) + current = replacement + } + try { + if (services.getValidatedMerklePath != null) { + await services.getValidatedMerklePath(candidate.txid, validate) + } else { + // Custom services remain compatible; one bounded lookup, fully validated. + await validate(await services.getMerklePath(candidate.txid)) + } + if (current != null) return current + } catch { + // Report a recoverable sync error, without leaking provider response data. + } + throw new WERR_INVALID_PARAMETER( + 'provenTx', + 'a server-verified proof. Merkle root is not active at the recorded height and a current proof could not be verified. ' + + 'Ask the source provider to reconcile this transaction, then resume synchronization; saved wallet data is unchanged.' + ) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.test.ts b/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.test.ts new file mode 100644 index 000000000..c1a0c61e8 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.test.ts @@ -0,0 +1,535 @@ +import 'fake-indexeddb/auto' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { StorageServer } from '../remoting/StorageServer' +import { StorageClient } from '../remoting/StorageClient' +import { KnexSessionManager } from '../remoting/KnexSessionManager' +import { Beef, MerklePath, PrivateKey, Script, Transaction, Validation } from '@bsv/sdk' +import { StorageIdb } from '../StorageIdb' +import { StorageProvider } from '../StorageProvider' +import { WalletStorageManager } from '../WalletStorageManager' +import { repairBeefProofs } from './repairBeefProofs' +import { validateSyncProof } from './validateSyncProof' +import { WERR_INVALID_MERKLE_ROOT } from '../../sdk/WERR_errors' +import { WalletErrorFromJson } from '../../sdk/WalletErrorFromJson' +import { toBinaryBaseBlockHeader } from '../../services/Services' +import { doubleSha256BE } from '../../utility/utilityHelpers' +import { asString } from '../../utility/utilityHelpers.noBuffer' +import type { WalletServices } from '../../sdk/WalletServices.interfaces' +import { _tu } from '../../../test/utils/TestUtilsWalletStorage' +import { managedChangeOutputFields } from './managedChange' +import { EntityProvenTxReq } from '../schema/entities/EntityProvenTxReq' +import type { RequestSyncChunkArgs, SyncChunk, WalletStorageSyncReader } from '../../sdk/WalletStorage.interfaces' + +function fixture() { + const tx = new Transaction() + tx.addOutput({ satoshis: 5000, lockingScript: Script.fromHex('51') }) + const txid = tx.id('hex') + const old = new MerklePath(100, [[{ offset: 0, hash: txid, txid: true }]]) + const current = new MerklePath(101, [ + [ + { offset: 0, hash: txid, txid: true }, + { offset: 1, hash: 'ab'.repeat(32) } + ] + ]) + const root = current.computeRoot(txid) + const header = toBinaryBaseBlockHeader({ + version: 1, + previousHash: '00'.repeat(32), + merkleRoot: root, + time: 1, + bits: 0, + nonce: 0 + }) + const record = { + provenTxId: 0, + txid, + rawTx: tx.toBinary(), + merklePath: old.toBinary(), + height: 100, + index: 0, + merkleRoot: old.computeRoot(txid), + blockHash: '11'.repeat(32), + created_at: new Date(0), + updated_at: new Date(1) + } + const getMerklePath = jest.fn(async () => ({ merklePath: current })) + const isValidRootForHeight = jest.fn( + async (candidate: string, height: number) => candidate === root && height === 101 + ) + const getHeaderForHeight = jest.fn(async () => [...header]) + const services = { + getMerklePath, + getHeaderForHeight, + getChainTracker: async () => ({ isValidRootForHeight }) + } as unknown as WalletServices + const beef = new Beef() + beef.mergeRawTx(record.rawTx) + beef.mergeBump(old) + return { beef, record, services, current, root, header, getMerklePath, isValidRootForHeight, getHeaderForHeight } +} + +async function seedFunding(storage: StorageProvider, f: ReturnType, identityKey: string) { + const { user } = await storage.findOrInsertUser(identityKey) + const basket = await storage.findOrInsertOutputBasket(user.userId, 'default') + await storage.updateOutputBasket(basket.basketId, { numberOfDesiredUTXOs: 0, minimumDesiredUTXOValue: 1 }) + const { tx } = await _tu.insertTestTransaction(storage, user, false, { + txid: f.record.txid, + rawTx: f.record.rawTx, + provenTxId: f.record.provenTxId, + status: 'completed' + }) + const output = await _tu.insertTestOutput(storage, tx, 0, 5000, basket, true, { + ...managedChangeOutputFields, + txid: f.record.txid, + lockingScript: [0x51], + scriptLength: 1, + derivationPrefix: 'dGVzdA==', + derivationSuffix: 'dGVzdA==' + }) + const args = Validation.validateCreateActionArgs({ + description: 'selected stale proof recovery', + outputs: [{ satoshis: 1000, lockingScript: '51', outputDescription: 'proof recovery output' }], + options: { noSend: true, returnTXIDOnly: false, randomizeOutputs: false } + }) + return { user, output, args } +} + +describe.each(['IndexedDB', 'SQLite'])('canonical BEEF recovery on %s', backend => { + let storage: StorageProvider + let cleanup: () => Promise + beforeEach(async () => { + if (backend === 'IndexedDB') { + const idb = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + idb.dbName = `proof-recovery-${randomUUID()}` + await idb.migrate('proof recovery', PrivateKey.fromRandom().toPublicKey().toString()) + await idb.makeAvailable() + storage = idb + cleanup = async () => { + await idb.destroy() + await idb.dropAllData() + } + } else { + const ctx = await _tu.createLegacyWalletSQLiteCopy('proof-recovery') + storage = ctx.activeStorage + cleanup = async () => { + await ctx.wallet.destroy() + } + } + }) + afterEach(async () => { + jest.restoreAllMocks() + await cleanup() + }) + + test('repairs stale selected or embedded ancestry, persists atomically and reuses the correction', async () => { + const f = fixture() + storage.setServices(f.services) + f.record.provenTxId = await storage.insertProvenTx(f.record) + const original = f.beef.toBinary() + const result = await repairBeefProofs(storage, f.beef) + expect(result.findTxid(f.record.txid)?.rawTx).toEqual(f.record.rawTx) + expect(result.bumps.map(path => path.toBinary())).toEqual([f.current.toBinary()]) + expect(f.beef.toBinary()).toEqual(original) + expect(f.getMerklePath).toHaveBeenCalledTimes(1) + const stored = (await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0] + expect(stored).toMatchObject({ height: 101, blockHash: asString(doubleSha256BE(f.header)), merkleRoot: f.root }) + expect(stored.updated_at.getTime()).toBeGreaterThan(f.record.updated_at.getTime()) + const repeated = await repairBeefProofs(storage, f.beef) + expect(repeated.toBinary()).toEqual(result.toBinary()) + expect(f.getMerklePath).toHaveBeenCalledTimes(1) + }) + + test.each(['absent', 'orphan', 'wrong header', 'throw'])( + 'fails with typed remote context for %s canonical evidence', + async failure => { + const f = fixture() + storage.setServices(f.services) + f.record.provenTxId = await storage.insertProvenTx(f.record) + const before = f.beef.toBinary() + if (failure === 'absent') f.getMerklePath.mockResolvedValue({ merklePath: undefined! }) + if (failure === 'orphan') f.isValidRootForHeight.mockResolvedValue(false) + if (failure === 'wrong header') f.getHeaderForHeight.mockResolvedValue(Array(80).fill(0)) + if (failure === 'throw') f.getMerklePath.mockRejectedValue(new Error('unavailable')) + const error = await repairBeefProofs(storage, f.beef).catch(error => error) + expect(error).toBeInstanceOf(WERR_INVALID_MERKLE_ROOT) + expect(WalletErrorFromJson(JSON.parse(error.toJson()))).toMatchObject({ txid: f.record.txid, blockHeight: 100 }) + expect(f.beef.toBinary()).toEqual(before) + expect(f.getMerklePath).toHaveBeenCalledTimes(1) + expect((await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0].height).toBe(100) + } + ) + + test('refuses unvalidated replacements and does not overwrite a concurrently changed proof', async () => { + const f = fixture() + storage.setServices(f.services) + f.record.provenTxId = await storage.insertProvenTx(f.record) + const replacement = { + ...f.record, + height: 101, + merklePath: f.current.toBinary(), + merkleRoot: f.root, + blockHash: asString(doubleSha256BE(f.header)) + } + await expect(storage.compareAndSetProvenTxProof(f.record, replacement)).rejects.toThrow( + 'requires active-chain validation' + ) + await validateSyncProof(storage, replacement) + await storage.updateProvenTx(f.record.provenTxId, { height: 102 }) + expect(await storage.compareAndSetProvenTxProof(f.record, replacement)).toBe(false) + expect((await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0].height).toBe(102) + }) + + test.each([false, true])( + 'prepared sync detaches payloads and fences concurrent proof changes (%s)', + async changed => { + const f = fixture() + storage.setServices(f.services) + f.record.provenTxId = await storage.insertProvenTx(f.record) + const identityKey = PrivateKey.fromRandom().toPublicKey().toString() + const source = PrivateKey.fromRandom().toPublicKey().toString() + const checkpoint = await storage.getSyncCheckpoint({ identityKey }, source, 'prepared fixture') + const args: RequestSyncChunkArgs = { + ...checkpoint, + identityKey, + fromStorageIdentityKey: source, + toStorageIdentityKey: storage.getSettings().storageIdentityKey, + maxItems: 64, + maxRoughSize: 1000000, + requireMatchingCheckpoint: true, + includeNextCheckpoint: true + } + const replacement = { + ...f.record, + height: 101, + merklePath: f.current.toBinary(), + merkleRoot: f.root, + blockHash: asString(doubleSha256BE(f.header)) + } + const chunk: SyncChunk = { + fromStorageIdentityKey: source, + toStorageIdentityKey: args.toStorageIdentityKey, + userIdentityKey: identityKey, + provenTxs: [replacement] + } + const apply = await storage.prepareSyncChunk(args, chunk) + if (changed) { + await storage.updateProvenTx(f.record.provenTxId, { height: 102 }) + await expect(apply()).rejects.toThrow('Proof changed during sync preparation') + expect(await storage.getSyncCheckpoint({ identityKey }, source, 'prepared fixture')).toEqual(checkpoint) + expect((await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0].height).toBe(102) + } else { + replacement.rawTx[0] ^= 1 + replacement.merklePath[0] ^= 1 + args.offsets[0].offset = 9999 + const committed = await apply() + expect(committed.nextCheckpoint?.offsets[0].offset).toBe(1) + expect((await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0]).toMatchObject({ + height: 101, + merkleRoot: f.root + }) + } + await expect(apply()).rejects.toThrow('already consumed') + } + ) + + test('sync proof I/O yields the manager queue and cancellation discards the prepared page', async () => { + const f = fixture() + storage.setServices(f.services) + f.record.provenTxId = await storage.insertProvenTx(f.record) + const identityKey = PrivateKey.fromRandom().toPublicKey().toString() + const manager = new WalletStorageManager(identityKey, storage) + await manager.makeAvailable() + const sourceSettings = { + ...storage.getSettings(), + storageIdentityKey: PrivateKey.fromRandom().toPublicKey().toString() + } + const reader = { + makeAvailable: async () => sourceSettings, + getSettings: () => sourceSettings, + getSyncChunk: async (args: RequestSyncChunkArgs): Promise => ({ + fromStorageIdentityKey: sourceSettings.storageIdentityKey, + toStorageIdentityKey: args.toStorageIdentityKey, + userIdentityKey: identityKey, + provenTxs: [ + { + ...f.record, + height: 101, + merklePath: f.current.toBinary(), + merkleRoot: f.root, + blockHash: asString(doubleSha256BE(f.header)) + } + ] + }) + } as WalletStorageSyncReader + let entered!: () => void + let release!: () => void + const started = new Promise(resolve => { + entered = resolve + }) + const gate = new Promise(resolve => { + release = resolve + }) + f.getHeaderForHeight.mockImplementation(async () => { + entered() + await gate + return f.header + }) + const controller = new AbortController() + const sync = manager.syncFromReaderResumable(identityKey, reader, { signal: controller.signal }) + await started + try { + const userId = await manager.getUserId() + await manager.runAsWriter(async () => await storage.findOrInsertTxLabel(userId, 'during proof lookup')) + expect(await manager.runAsReader(async () => await storage.countTxLabels({ partial: { userId } }))).toBe(1) + controller.abort() + } finally { + release() + } + expect(await sync).toMatchObject({ mode: 'paged', status: 'cancelled', pages: 0 }) + expect((await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0].height).toBe(100) + }, 10000) + + test.each(['direct', 'header', 'height'])( + 'monitor %s recovery cannot overwrite a concurrent proof correction', + async mode => { + const f = fixture() + const manager = new WalletStorageManager(PrivateKey.fromRandom().toPublicKey().toString(), storage) + await manager.makeAvailable() + manager.setServices(f.services) + f.record.provenTxId = await storage.insertProvenTx(f.record) + let entered!: () => void + let release!: () => void + const started = new Promise(resolve => { + entered = resolve + }) + const paused = new Promise(resolve => { + release = resolve + }) + f.getMerklePath.mockImplementation(async () => { + entered() + await paused + return { merklePath: f.current } + }) + const recovering = + mode === 'direct' + ? manager.reproveProven(f.record) + : mode === 'header' + ? manager.reproveHeader(f.record.blockHash) + : manager.reproveHeightMerkleRoot(f.record.height, f.record.merkleRoot) + await started + try { + await manager.runAsWriter(async () => await storage.updateProvenTx(f.record.provenTxId, { height: 102 })) + } finally { + release() + } + const result = await recovering + expect(result.updated == null || (Array.isArray(result.updated) && result.updated.length === 0)).toBe(true) + expect(result.log).toContain('changed concurrently') + expect((await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0].height).toBe(102) + }, + 10000 + ) + + test('monitor recovery fences primary replacement before committing a late proof', async () => { + const f = fixture() + const manager = new WalletStorageManager(PrivateKey.fromRandom().toPublicKey().toString(), storage) + const next = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + next.dbName = `reproof-next-${randomUUID()}` + await next.migrate('reproof replacement', PrivateKey.fromRandom().toPublicKey().toString()) + await manager.makeAvailable() + await manager.addWalletStorageProvider(next) + await manager.setActive(storage.getSettings().storageIdentityKey) + manager.setServices(f.services) + f.record.provenTxId = await storage.insertProvenTx(f.record) + let entered!: () => void + let release!: () => void + const started = new Promise(resolve => { + entered = resolve + }) + const paused = new Promise(resolve => { + release = resolve + }) + f.getMerklePath.mockImplementation(async () => { + entered() + await paused + return { merklePath: f.current } + }) + const recovering = manager.reproveHeader(f.record.blockHash) + const rejected = expect(recovering).rejects.toThrow('Proof destination changed') + await started + try { + await manager.setActive(next.getSettings().storageIdentityKey) + } finally { + release() + } + try { + await rejected + expect((await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0].height).toBe(100) + expect(await next.findProvenTxs({ partial: { txid: f.record.txid } })).toHaveLength(0) + } finally { + await next.destroy() + await next.dropAllData() + } + }, 10000) + + test('repairs embedded ancestry absent from storage and preserves txid-only references', async () => { + const f = fixture() + storage.setServices(f.services) + const reference = 'ac'.repeat(32) + f.beef.mergeTxidOnly(reference) + const repaired = await repairBeefProofs(storage, f.beef) + expect(repaired.findTxid(reference)?.isTxidOnly).toBe(true) + expect(repaired.findTxid(f.record.txid)?.rawTx).toEqual(f.record.rawTx) + expect(repaired.bumps[0].computeRoot(f.record.txid)).toBe(f.root) + expect(await storage.countProvenTxs({ partial: { txid: f.record.txid } })).toBe(0) + }) + + test('reuses a concurrent canonical database correction without another provider request', async () => { + const f = fixture() + storage.setServices(f.services) + await storage.insertProvenTx({ + ...f.record, + height: 101, + merklePath: f.current.toBinary(), + merkleRoot: f.root, + blockHash: asString(doubleSha256BE(f.header)) + }) + const persist = jest.spyOn(storage, 'compareAndSetProvenTxProof') + const repaired = await repairBeefProofs(storage, f.beef) + expect(repaired.bumps[0].computeRoot(f.record.txid)).toBe(f.root) + expect(f.getMerklePath).not.toHaveBeenCalled() + expect(persist).not.toHaveBeenCalled() + }) + + test('a provider without atomic proof replacement can repair outgoing BEEF without changing shared storage', async () => { + const f = fixture() + storage.setServices(f.services) + await storage.insertProvenTx(f.record) + jest + .spyOn(storage, 'compareAndSetProvenTxProof') + .mockImplementation(StorageProvider.prototype.compareAndSetProvenTxProof.bind(storage)) + const repaired = await repairBeefProofs(storage, f.beef) + expect(repaired.bumps[0].computeRoot(f.record.txid)).toBe(f.root) + expect((await storage.findProvenTxs({ partial: { txid: f.record.txid } }))[0].height).toBe(100) + }) + + test('keeps valid proof assembly free of copies and storage/provider lookups', async () => { + const f = fixture() + storage.setServices(f.services) + f.isValidRootForHeight.mockResolvedValue(true) + const find = jest.spyOn(storage, 'findProvenTxs') + expect(await repairBeefProofs(storage, f.beef)).toBe(f.beef) + expect(find).not.toHaveBeenCalled() + expect(f.getMerklePath).not.toHaveBeenCalled() + expect(f.isValidRootForHeight).toHaveBeenCalledTimes(1) + }) + + test.each([false, true])( + 'validates selected change before returning to the signer (recovery unavailable=%s)', + async unavailable => { + const f = fixture() + storage.setServices(f.services) + f.record.provenTxId = await storage.insertProvenTx(f.record) + const { user, output, args } = await seedFunding(storage, f, PrivateKey.fromRandom().toPublicKey().toString()) + if (unavailable) { + f.getMerklePath.mockResolvedValue({ merklePath: undefined! }) + await expect( + storage.createAction({ identityKey: user.identityKey, userId: user.userId }, args) + ).rejects.toBeInstanceOf(WERR_INVALID_MERKLE_ROOT) + const retained = (await storage.findOutputs({ partial: { outputId: output.outputId } }))[0] + expect(retained.spendable).toBe(true) + expect(retained.spentBy).toBeUndefined() + expect(await storage.findTransactions({ partial: { userId: user.userId }, status: ['failed'] })).toHaveLength(1) + f.getMerklePath.mockResolvedValue({ merklePath: f.current }) + } + const created = await storage.createAction({ identityKey: user.identityKey, userId: user.userId }, args) + expect(Beef.fromBinary(created.inputBeef!).bumps.map(bump => bump.computeRoot())).toEqual([f.root]) + expect(created.inputs).toHaveLength(1) + expect(created.inputs[0].sourceTxid).toBe(f.record.txid) + } + ) + + test.each([false, true])( + 'checks the final rebuilt send bundle before broadcast (recovery unavailable=%s)', + async unavailable => { + const f = fixture() + const stop = new Error('stop at controlled broadcaster') + const postBeef = jest.fn(async (_beef: Beef) => { + throw stop + }) + storage.setServices({ ...f.services, postBeef } as WalletServices) + f.record.provenTxId = await storage.insertProvenTx(f.record) + const tx = new Transaction() + tx.addInput({ sourceTXID: f.record.txid, sourceOutputIndex: 0, unlockingScript: Script.fromHex('') }) + tx.addOutput({ satoshis: 4999, lockingScript: Script.fromHex('51') }) + const req = EntityProvenTxReq.fromTxid(tx.id('hex'), tx.toBinary(), f.beef.toBinary()) + req.notify.transactionIds = [1] + if (unavailable) f.getMerklePath.mockResolvedValue({ merklePath: undefined! }) + const result = storage.attemptToPostReqsToNetwork([req]) + if (unavailable) { + await expect(result).rejects.toBeInstanceOf(WERR_INVALID_MERKLE_ROOT) + expect(postBeef).not.toHaveBeenCalled() + expect(req.attempts).toBe(0) + } else { + await expect(result).rejects.toBe(stop) + expect(postBeef).toHaveBeenCalledTimes(1) + expect(postBeef.mock.calls[0][0].bumps.map(bump => bump.computeRoot())).toEqual([f.root]) + } + } + ) +}) + +test('remote selected-change recovery binds the authenticated wallet and preserves typed errors', async () => { + const ctx = await _tu.createLegacyWalletSQLiteCopy('remote-proof-recovery') + const server = new StorageServer(ctx.activeStorage, { + port: 0, + wallet: ctx.wallet, + monetize: false, + logRpcRequests: false, + sessionManager: new KnexSessionManager(ctx.activeStorage.knex), + adminIdentityKeys: [], + calculateRequestPrice: async () => 0 + }) + server.start() + if (!server.server.listening) await once(server.server, 'listening') + const address = server.server.address() + if (address == null || typeof address === 'string') throw new Error('server did not bind') + const endpointUrl = `http://localhost:${address.port}` + const client = await _tu.createTestWalletWithStorageClient({ + rootKeyHex: ctx.rootKey.toHex(), + endpointUrl, + chain: ctx.chain + }) + const stranger = await _tu.createTestWalletWithStorageClient({ + rootKeyHex: '2'.repeat(64), + endpointUrl, + chain: ctx.chain + }) + try { + const f = fixture() + ctx.activeStorage.setServices(f.services) + for (const output of await ctx.activeStorage.findOutputs({ partial: { userId: ctx.userId }, noScript: true })) { + if (output.spendable) await ctx.activeStorage.updateOutput(output.outputId, { spendable: false }) + } + f.record.provenTxId = await ctx.activeStorage.insertProvenTx(f.record) + const { args } = await seedFunding(ctx.activeStorage, f, ctx.identityKey) + const forgedAuth = { identityKey: ctx.identityKey, userId: ctx.userId, isActive: true } + await expect((stranger.storage.getActive() as StorageClient).createAction(forgedAuth, args)).rejects.toThrow() + expect(f.getMerklePath).not.toHaveBeenCalled() + f.getMerklePath.mockResolvedValue({ merklePath: undefined! }) + await expect((client.storage.getActive() as StorageClient).createAction(forgedAuth, args)).rejects.toMatchObject({ + name: 'WERR_INVALID_MERKLE_ROOT', + txid: f.record.txid, + blockHeight: 100 + }) + f.getMerklePath.mockResolvedValue({ merklePath: f.current }) + const created = await (client.storage.getActive() as StorageClient).createAction(forgedAuth, args) + expect(Beef.fromBinary(created.inputBeef!).bumps.map(bump => bump.computeRoot())).toEqual([f.root]) + } finally { + await stranger.wallet.destroy() + await client.wallet.destroy() + await server.close() + await ctx.wallet.destroy() + } +}, 30000) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts b/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts new file mode 100644 index 000000000..72b9d34d2 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts @@ -0,0 +1,97 @@ +import { findProofRecords, mapProofWork } from './proofWork' +import { Beef, MerklePath } from '@bsv/sdk' +import { WERR_INVALID_MERKLE_ROOT } from '../../sdk/WERR_errors' +import type { TrxToken } from '../../sdk/WalletStorage.interfaces' +import type { StorageProvider } from '../StorageProvider' +import type { TableProvenTx } from '../schema/tables' +import { refreshSyncProof } from './refreshSyncProof' +import { validateSyncProof } from './validateSyncProof' + +/** + * Validate the assembled graph, including cached and embedded ancestry. Only a + * stale root triggers canonical recovery. Never change transaction bytes or + * treat provider absence as a successful proof. The common valid path neither + * copies the graph nor reads proof records from storage. + */ +export async function repairBeefProofs(storage: StorageProvider, beef: Beef, trx?: TrxToken): Promise { + if (beef.bumps.length === 0) return beef + const services = storage.getServices() + const tracker = await services.getChainTracker() + const roots = new Map() + for (const bump of beef.bumps) { + const root = bump.computeRoot() + roots.set(`${bump.blockHeight}:${root}`, { root, height: bump.blockHeight }) + } + const checks = await mapProofWork( + [...roots.entries()], + async ([key, { root, height }]) => [key, (await tracker.isValidRootForHeight(root, height)) === true] as const + ) + const valid = new Map(checks) + const stale = new Set(beef.bumps.filter(bump => !valid.get(`${bump.blockHeight}:${bump.computeRoot()}`))) + if (stale.size === 0) return beef + + const affected = beef.txs.filter(tx => tx.bumpIndex != null && stale.has(beef.bumps[tx.bumpIndex])) + const records = await findProofRecords( + storage, + affected.map(tx => tx.txid), + trx + ) + const existing = new Map(records.map(record => [record.txid, record])) + const replacements = await mapProofWork(affected, async tx => { + const oldPath = beef.bumps[tx.bumpIndex!] + const record = existing.get(tx.txid) + if (record != null) { + try { + // Another request may already have repaired the shared proof row. + await validateSyncProof(storage, record) + return { proof: record } + } catch { + // Recovery still requires full validation of fresh canonical evidence. + } + } + const candidate: TableProvenTx = { + provenTxId: record?.provenTxId ?? 0, + created_at: record?.created_at ?? new Date(0), + updated_at: record?.updated_at ?? new Date(0), + txid: tx.txid, + rawTx: tx.rawTx ?? [], + height: oldPath.blockHeight, + merklePath: oldPath.toBinary(), + merkleRoot: oldPath.computeRoot(tx.txid), + blockHash: record?.blockHash ?? '00'.repeat(32), + index: oldPath.path[0].find(leaf => leaf.hash === tx.txid)?.offset ?? -1 + } + try { + return { proof: await refreshSyncProof(storage, candidate), expected: record } + } catch { + const error = new WERR_INVALID_MERKLE_ROOT(candidate.blockHash, candidate.height, candidate.merkleRoot, tx.txid) + error.message += + ' A current canonical proof could not be verified. Retry after the proof provider recovers; no transaction was broadcast by proof recovery.' + throw error + } + }) + + // Construct independently: failed recovery must not leave a half-edited BEEF. + const repaired = new Beef(beef.version) + for (const tx of beef.txs) { + const bytes = tx.rawTxUint8Array + if (bytes == null) repaired.mergeTxidOnly(tx.txid) + else repaired.mergeRawTx(bytes) + } + for (const bump of beef.bumps) if (!stale.has(bump)) repaired.mergeBump(bump) + for (const { proof } of replacements) repaired.mergeBump(MerklePath.fromBinary(proof.merklePath)) + + // Persistence is optional for custom providers. Built-ins compare all proof + // authority fields atomically, so delayed I/O cannot overwrite a newer proof. + if (replacements.some(result => result.expected != null)) { + await storage.transaction(async trx => { + let changed = false + for (const { proof, expected } of replacements) { + if (expected != null) changed = (await storage.compareAndSetProvenTxProof(expected, proof, trx)) || changed + } + const extension = storage as StorageProvider & { invalidatePreparedBeefs?: (trx?: TrxToken) => Promise } + if (changed) await extension.invalidatePreparedBeefs?.(trx) + }, trx) + } + return repaired +} diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.ts b/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.ts index ff9d58676..3feee30bf 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.ts @@ -85,6 +85,22 @@ export function assertSyncProofReplacementAuthorized(candidate: TableProvenTx): invalidSyncProof('replacement requires active-chain validation') } +/** Restrict recovery updates to validated proof authority and a discoverable timestamp. */ +export function recoveredProofUpdate(expected: TableProvenTx, replacement: TableProvenTx): Partial { + assertSyncProofReplacementAuthorized(replacement) + if (expected.txid !== replacement.txid || !equalBytes(expected.rawTx, replacement.rawTx)) { + invalidSyncProof('recovery cannot change transaction identity or bytes') + } + return { + height: replacement.height, + index: replacement.index, + merklePath: replacement.merklePath, + merkleRoot: replacement.merkleRoot, + blockHash: replacement.blockHash, + updated_at: new Date(Math.max(Date.now(), expected.updated_at.getTime() + 1)) + } +} + /** * Validate proof authority before an RPC proof is admitted or an in-process * backup/conflict proof replaces an existing global row. Network-backed checks diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/validateRpcSyncProofs.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/validateRpcSyncProofs.ts index 98f19d9ed..ee84aae90 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/validateRpcSyncProofs.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/validateRpcSyncProofs.ts @@ -1,50 +1,13 @@ -import { WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' -import type { GetMerklePathResult } from '../../sdk/WalletServices.interfaces' -import { doubleSha256BE } from '../../utility/utilityHelpers' -import { asString } from '../../utility/utilityHelpers.noBuffer' -import { StaleSyncProofError, validateSyncProof, markSyncProofReconciled } from '../methods/validateSyncProof' +import { StaleSyncProofError, validateSyncProof } from '../methods/validateSyncProof' import type { SyncProofValidationStorage } from '../methods/validateSyncProof' import type { TableProvenTx } from '../schema/tables/TableProvenTx' - -/** Obtain replacement metadata; never alter transaction bytes, IDs or timestamps. */ -async function refreshSyncProof(storage: SyncProofValidationStorage, candidate: TableProvenTx): Promise { - const services = storage.getServices() - let current: TableProvenTx | undefined - const validate = async ({ merklePath }: GetMerklePathResult): Promise => { - if (merklePath == null) throw new Error('Current proof unavailable') - const header = await services.getHeaderForHeight(merklePath.blockHeight) - const replacement = { - ...candidate, - height: merklePath.blockHeight, - index: merklePath.path[0]?.find(leaf => leaf.txid === true && leaf.hash === candidate.txid)?.offset ?? -1, - merklePath: merklePath.toBinary(), - merkleRoot: merklePath.computeRoot(candidate.txid), - blockHash: asString(doubleSha256BE(header)) - } - // Recheck raw bytes, membership, root and active header before authorizing - // any global proof replacement. A provider's success response is insufficient. - await validateSyncProof(storage, replacement) - markSyncProofReconciled(replacement) - current = replacement - } - try { - if (services.getValidatedMerklePath != null) { - await services.getValidatedMerklePath(candidate.txid, validate) - } else { - // Custom services remain compatible; one bounded lookup, fully validated. - await validate(await services.getMerklePath(candidate.txid)) - } - if (current != null) return current - } catch { - // Report a recoverable sync error, without leaking provider response data. - } - throw new WERR_INVALID_PARAMETER('provenTx', - 'a server-verified proof. Merkle root is not active at the recorded height and a current proof could not be verified. ' + - 'Ask the source provider to reconcile this transaction, then resume synchronization; saved wallet data is unchanged.') -} +import { refreshSyncProof } from '../methods/refreshSyncProof' /** Validate a whole RPC page with at most eight proofs in flight and no database writes. */ -export async function validateSyncProofs(storage: SyncProofValidationStorage, candidates: TableProvenTx[]): Promise { +export async function validateSyncProofs( + storage: SyncProofValidationStorage, + candidates: TableProvenTx[] +): Promise { let next = 0 let failed = false const validated: TableProvenTx[] = [] diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntitySyncState.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntitySyncState.ts index dd8cd13fb..b762b11b5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntitySyncState.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntitySyncState.ts @@ -312,7 +312,7 @@ export class EntitySyncState extends EntityBase { maxRoughSize: maxRoughSize || 10000000, maxItems: maxItems || 1000, offsets: [], - since: this.when, + since: this.when == null ? undefined : new Date(this.when), fromStorageIdentityKey: this.storageIdentityKey, toStorageIdentityKey: forStorageIdentityKey } @@ -338,7 +338,11 @@ export class EntitySyncState extends EntityBase { /** Return progress without the potentially large writer-local ID maps. */ makeSyncCheckpoint(): SyncCheckpoint { const request = this.makeRequestSyncChunkArgs('', '') - return { syncStateId: this.id, since: this.when == null ? undefined : new Date(this.when), offsets: request.offsets } + return { + syncStateId: this.id, + since: this.when == null ? undefined : new Date(this.when), + offsets: request.offsets + } } static syncChunkSummary(c: SyncChunk): string { diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/ProvenTxTests.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/ProvenTxTests.test.ts index a811276dc..5d933f08e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/ProvenTxTests.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/ProvenTxTests.test.ts @@ -14,7 +14,7 @@ function makeServerVerifiedProof( height: number, time: number, provenTxId = 1 -): { proof: TableProvenTx, header: number[] } { +): { proof: TableProvenTx; header: number[] } { const txid = transaction.id('hex') const merklePath = new bsv.MerklePath(height, [[{ offset: 0, hash: txid, txid: true }]]) const merkleRoot = merklePath.computeRoot(txid) @@ -49,7 +49,7 @@ function makeProofStorage(header: number[]): StorageProvider & { updateProvenTx: jest.Mock invalidatePreparedBeefs: jest.Mock } { - return { + return Object.assign(Object.create(StorageProvider.prototype), { getServices: () => ({ getChainTracker: async () => ({ isValidRootForHeight: async () => true }), getHeaderForHeight: async () => header @@ -57,7 +57,7 @@ function makeProofStorage(header: number[]): StorageProvider & { insertProvenTx: jest.fn(async () => 77), updateProvenTx: jest.fn(async () => 1), invalidatePreparedBeefs: jest.fn(async () => 0) - } as unknown as StorageProvider & { + }) as StorageProvider & { insertProvenTx: jest.Mock updateProvenTx: jest.Mock invalidatePreparedBeefs: jest.Mock @@ -561,8 +561,9 @@ describe('ProvenTx class method tests', () => { const corrupted = { ...proof, rawTx: [...proof.rawTx] } corrupted.rawTx[corrupted.rawTx.length - 1] ^= 1 - await expect(syncProofValidation.validateSyncProof(storage, corrupted)) - .rejects.toMatchObject({ code: 'WERR_INVALID_PARAMETER' }) + await expect(syncProofValidation.validateSyncProof(storage, corrupted)).rejects.toMatchObject({ + code: 'WERR_INVALID_PARAMETER' + }) expect(storage.insertProvenTx).not.toHaveBeenCalled() expect(storage.invalidatePreparedBeefs).not.toHaveBeenCalled() @@ -591,17 +592,16 @@ describe('ProvenTx class method tests', () => { const entity = new EntityProvenTx(original.proof) await syncProofValidation.validateSyncProof(storage, replacement.proof) - await expect(entity.mergeExisting( - storage, - undefined, - replacement.proof, - createSyncMap() - )).resolves.toBe(true) - - expect(storage.updateProvenTx).toHaveBeenCalledWith(44, expect.objectContaining({ - height: 103, - blockHash: replacement.proof.blockHash - }), undefined) + await expect(entity.mergeExisting(storage, undefined, replacement.proof, createSyncMap())).resolves.toBe(true) + + expect(storage.updateProvenTx).toHaveBeenCalledWith( + 44, + expect.objectContaining({ + height: 103, + blockHash: replacement.proof.blockHash + }), + undefined + ) expect(storage.invalidatePreparedBeefs).toHaveBeenCalledWith(undefined) }) @@ -617,11 +617,13 @@ describe('ProvenTx class method tests', () => { storage.transaction = jest.fn() storage.findProvenTxs = jest.fn(async () => [active.proof]) - await expect(StorageProvider.prototype.processSyncChunk.call( - storage, - {} as sdk.RequestSyncChunkArgs, - { provenTxs: [stale.proof] } as sdk.SyncChunk - )).rejects.toMatchObject({ code: 'WERR_INVALID_PARAMETER' }) + await expect( + StorageProvider.prototype.processSyncChunk.call( + storage, + {} as sdk.RequestSyncChunkArgs, + { provenTxs: [stale.proof] } as sdk.SyncChunk + ) + ).rejects.toMatchObject({ code: 'WERR_INVALID_PARAMETER' }) expect(storage.transaction).not.toHaveBeenCalled() }) @@ -641,11 +643,13 @@ describe('ProvenTx class method tests', () => { storage.transaction = jest.fn(async () => merged) storage.findProvenTxs = jest.fn(async () => []) - await expect(StorageProvider.prototype.processSyncChunk.call( - storage, - {} as sdk.RequestSyncChunkArgs, - { provenTxs: [proof] } as sdk.SyncChunk - )).resolves.toEqual(merged) + await expect( + StorageProvider.prototype.processSyncChunk.call( + storage, + {} as sdk.RequestSyncChunkArgs, + { provenTxs: [proof] } as sdk.SyncChunk + ) + ).resolves.toEqual(merged) expect(proof).toMatchObject({ txid: proof.txid.toLowerCase(), @@ -663,12 +667,9 @@ describe('ProvenTx class method tests', () => { const storage = makeProofStorage(unvalidated.header) const entity = new EntityProvenTx(original.proof) - await expect(entity.mergeExisting( - storage, - undefined, - unvalidated.proof, - createSyncMap() - )).rejects.toMatchObject({ code: 'WERR_INVALID_PARAMETER' }) + await expect(entity.mergeExisting(storage, undefined, unvalidated.proof, createSyncMap())).rejects.toMatchObject({ + code: 'WERR_INVALID_PARAMETER' + }) expect(storage.updateProvenTx).not.toHaveBeenCalled() }) @@ -689,8 +690,9 @@ describe('ProvenTx class method tests', () => { ] for (const candidate of malformed) { - await expect(syncProofValidation.validateSyncProof(storage, candidate)) - .rejects.toMatchObject({ code: 'WERR_INVALID_PARAMETER' }) + await expect(syncProofValidation.validateSyncProof(storage, candidate)).rejects.toMatchObject({ + code: 'WERR_INVALID_PARAMETER' + }) } }) @@ -698,11 +700,8 @@ describe('ProvenTx class method tests', () => { const transaction = new bsv.Transaction() transaction.addOutput({ satoshis: 1, lockingScript: bsv.Script.fromHex('51') }) const { proof, header } = makeServerVerifiedProof(transaction, 110, 11) - const wrongHeightPath = new bsv.MerklePath( - proof.height + 1, - [[{ offset: 0, hash: proof.txid, txid: true }]] - ) - const invalidCases: Array<{ candidate: TableProvenTx, storage: StorageProvider }> = [ + const wrongHeightPath = new bsv.MerklePath(proof.height + 1, [[{ offset: 0, hash: proof.txid, txid: true }]]) + const invalidCases: Array<{ candidate: TableProvenTx; storage: StorageProvider }> = [ { candidate: { ...proof, merklePath: wrongHeightPath.toBinary() }, storage: makeProofStorage(header) }, { candidate: { ...proof, index: 1 }, storage: makeProofStorage(header) }, { candidate: { ...proof, merkleRoot: 'f'.repeat(64) }, storage: makeProofStorage(header) }, @@ -719,7 +718,9 @@ describe('ProvenTx class method tests', () => { candidate: { ...proof }, storage: { getServices: () => ({ - getChainTracker: async () => { throw new Error('chain tracker unavailable') }, + getChainTracker: async () => { + throw new Error('chain tracker unavailable') + }, getHeaderForHeight: async () => header }) } as unknown as StorageProvider @@ -729,8 +730,9 @@ describe('ProvenTx class method tests', () => { ] for (const { candidate, storage } of invalidCases) { - await expect(syncProofValidation.validateSyncProof(storage, candidate)) - .rejects.toMatchObject({ code: 'WERR_INVALID_PARAMETER' }) + await expect(syncProofValidation.validateSyncProof(storage, candidate)).rejects.toMatchObject({ + code: 'WERR_INVALID_PARAMETER' + }) } }) @@ -741,8 +743,8 @@ describe('ProvenTx class method tests', () => { syncProofValidation.markSyncProofInsertOnly(proof) - expect(() => syncProofValidation.assertSyncProofReplacementAuthorized(proof)) - .toThrow('concurrent proof row appeared') + expect(() => syncProofValidation.assertSyncProofReplacementAuthorized(proof)).toThrow( + 'concurrent proof row appeared' + ) }) - }) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.test.ts new file mode 100644 index 000000000..fd04e7c77 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.test.ts @@ -0,0 +1,95 @@ +import { StorageAccessQueue } from './StorageAccessQueue' + +test('shares bounded reads, fences writers and does not let later readers starve a writer', async () => { + const queue = new StorageAccessQueue() + const readers = await Promise.all(Array.from({ length: 8 }, async () => await queue.acquire('read'))) + let writerStarted = false + const writer = queue.acquire('exclusive').then(release => { + writerStarted = true + return release + }) + let laterStarted = false + const later = queue.acquire('read').then(release => { + laterStarted = true + return release + }) + await Promise.resolve() + expect(writerStarted).toBe(false) + for (const release of readers.slice(0, 7)) release() + await Promise.resolve() + expect(writerStarted).toBe(false) + readers[7]() + const releaseWriter = await writer + expect(laterStarted).toBe(false) + releaseWriter() + const releaseLater = await later + expect(laterStarted).toBe(true) + releaseLater() + // A duplicate completion cannot release another operation's ownership. + releaseWriter() + releaseLater() + const next = await queue.acquire('exclusive') + next() +}) + +test('admits at most eight concurrent readers', async () => { + const queue = new StorageAccessQueue() + const readers = await Promise.all(Array.from({ length: 8 }, async () => await queue.acquire('read'))) + let started = false + const ninth = queue.acquire('read').then(release => { + started = true + return release + }) + await Promise.resolve() + expect(started).toBe(false) + readers[0]() + const release = await ninth + for (const done of readers) done() + release() +}) + +test('prefers foreground work while guaranteeing a waiting background page within eight grants', async () => { + const queue = new StorageAccessQueue() + const unblock = await queue.acquire('exclusive') + const order: string[] = [] + const background = queue.acquire('exclusive', 'background').then(release => { + order.push('background') + release() + }) + const foreground = Array.from({ length: 20 }, (_, index) => + queue.acquire('exclusive').then(release => { + order.push(`foreground ${index}`) + release() + }) + ) + unblock() + await Promise.all([background, ...foreground]) + expect(order[0]).toBe('foreground 0') + expect(order.indexOf('background')).toBeLessThanOrEqual(8) + expect(order.filter(item => item !== 'background')).toEqual( + Array.from({ length: 20 }, (_, index) => `foreground ${index}`) + ) +}) + +test('ages a background page ahead of newly queued foreground work', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1000) + try { + const queue = new StorageAccessQueue() + const unblock = await queue.acquire('exclusive') + const order: string[] = [] + const background = queue.acquire('exclusive', 'background').then(release => { + order.push('background') + release() + }) + now.mockReturnValue(2001) + const foreground = queue.acquire('exclusive').then(release => { + order.push('foreground') + release() + }) + unblock() + await Promise.all([background, foreground]) + expect(order).toEqual(['background', 'foreground']) + } finally { + now.mockRestore() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts new file mode 100644 index 000000000..59d804705 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts @@ -0,0 +1,55 @@ +type Mode = 'read' | 'exclusive' +type Priority = 'foreground' | 'background' +interface Waiter { + mode: Mode + priority: Priority + queuedAt: number + grant: (release: () => void) => void +} + +/** + * One ownership queue: exclusive operations never overlap; opted-in reads + * share up to eight slots. Foreground work may pass a background page at most + * eight times or for one second. FIFO order is retained within each priority. + */ +export class StorageAccessQueue { + private readonly waiters: Waiter[] = [] + private readers = 0 + private exclusive = false + private foregroundGrants = 0 + + acquire(mode: Mode, priority: Priority = 'foreground'): Promise<() => void> { + return new Promise(resolve => { + this.waiters.push({ mode, priority, queuedAt: Date.now(), grant: resolve }) + this.pump() + }) + } + + private nextIndex(): number { + const background = this.waiters.findIndex(waiter => waiter.priority === 'background') + if (background >= 0 && (this.foregroundGrants >= 8 || Date.now() - this.waiters[background].queuedAt >= 1000)) + return background + const foreground = this.waiters.findIndex(waiter => waiter.priority === 'foreground') + return foreground >= 0 ? foreground : 0 + } + + private pump(): void { + while (!this.exclusive && this.waiters.length > 0) { + const index = this.nextIndex() + const waiter = this.waiters[index] + if (waiter.mode === 'exclusive' ? this.readers > 0 : this.readers >= 8) return + this.waiters.splice(index, 1) + if (waiter.mode === 'exclusive') this.exclusive = true + else this.readers++ + this.foregroundGrants = waiter.priority === 'background' ? 0 : Math.min(8, this.foregroundGrants + 1) + let released = false + waiter.grant(() => { + if (released) return + released = true + if (waiter.mode === 'exclusive') this.exclusive = false + else this.readers-- + this.pump() + }) + } + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts index f3e7eefb7..6030ed029 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts @@ -1,9 +1,15 @@ import { SyncPageBudget } from './SyncPageBudget' import type { RequestSyncChunkArgs, SyncChunk } from '../../sdk/WalletStorage.interfaces' -const args = { maxItems: 1000, maxRoughSize: 2000000, offsets: [{ name: 'provenTx', offset: 700 }] } as RequestSyncChunkArgs +const args = { + maxItems: 1000, + maxRoughSize: 2000000, + offsets: [{ name: 'provenTx', offset: 700 }] +} as RequestSyncChunkArgs const chunk = (count: number, proof = false): SyncChunk => ({ - fromStorageIdentityKey: 'from', toStorageIdentityKey: 'to', userIdentityKey: 'user', + fromStorageIdentityKey: 'from', + toStorageIdentityKey: 'to', + userIdentityKey: 'user', [proof ? 'provenTxs' : 'transactions']: Array.from({ length: count }, () => ({})) }) @@ -37,3 +43,56 @@ test('ignores unusable timings and empty pages and starts each copy independentl expect(budget.apply(args).maxItems).toBe(128) expect(new SyncPageBudget().apply(args).maxItems).toBe(64) }) + +test.each([false, true])('amortizes fixed latency without collapsing to single rows (proofs=%s)', proof => { + const budget = new SyncPageBudget() + const limits: number[] = [] + let remaining = 10000 + let pages = 0 + while (remaining > 0) { + const limit = budget.apply(args).maxItems + limits.push(limit) + const count = Math.min(limit, remaining) + // Independent fixed costs in the source request and destination commit. + budget.committed(chunk(count, proof), 20000 + count * 2, 15000 + count) + remaining -= count + pages++ + expect(pages).toBeLessThan(200) + } + expect(Math.min(...limits)).toBeGreaterThan(1) + expect(limits.slice(-5).every(limit => limit === (proof ? 128 : 1000))).toBe(true) +}) + +test('distinguishes per-proof work from fixed overhead and responds to a slowdown', () => { + const budget = new SyncPageBudget() + for (let i = 0; i < 10; i++) { + const count = budget.apply(args).maxItems + budget.committed(chunk(count, true), 16000 + count * 100, 15000 + count * 20) + } + expect(budget.apply(args).maxItems).toBeGreaterThanOrEqual(49) + expect(budget.apply(args).maxItems).toBeLessThanOrEqual(51) + const before = budget.apply(args).maxItems + budget.committed(chunk(before, true), 16000 + before * 1000, 15000 + before * 20) + expect(budget.apply(args).maxItems).toBeLessThan(before / 2) +}) + +test('recovers from the single-row floor using bounded upward probes', () => { + const budget = new SyncPageBudget() + budget.committed(chunk(1, true), 20000, 19000) + const limits: number[] = [] + for (let i = 0; i < 30; i++) { + const count = budget.apply(args).maxItems + limits.push(count) + budget.committed(chunk(count, true), 20000, 19000) + } + expect(limits[0]).toBe(1) + expect(limits.slice(0, 4)).toContain(2) + expect(limits.slice(-5)).toEqual([128, 128, 128, 128, 128]) +}) + +test('does not reuse cheap metadata estimates for expensive proofs', () => { + const budget = new SyncPageBudget() + for (let i = 0; i < 10; i++) budget.committed(chunk(budget.apply(args).maxItems), 20000, 19000) + budget.committed(chunk(100, true), 50000, 1000) + expect(budget.apply(args).maxItems).toBeLessThanOrEqual(10) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts index e03eae666..a33b16827 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts @@ -1,22 +1,87 @@ import type { RequestSyncChunkArgs, SyncChunk } from '../../sdk/WalletStorage.interfaces' +interface PageCost { + records: number + readMs: number + commitMs: number +} + +/** Fit fixed overhead separately from marginal work, using bounded recent history. */ +function marginalCost( + samples: PageCost[], + phase: 'readMs' | 'commitMs', + previousFixedMs: number +): { perRecordMs: number; fixedMs: number } { + const averageRecords = samples.reduce((sum, sample) => sum + sample.records, 0) / samples.length + const averageMs = samples.reduce((sum, sample) => sum + sample[phase], 0) / samples.length + const variance = samples.reduce((sum, sample) => sum + (sample.records - averageRecords) ** 2, 0) + // Until page sizes differ there is no evidence that any cost is fixed. + if (variance === 0) { + const fixedMs = Math.min(previousFixedMs, averageMs) + return { perRecordMs: (averageMs - fixedMs) / averageRecords, fixedMs } + } + const covariance = samples.reduce( + (sum, sample) => sum + (sample.records - averageRecords) * (sample[phase] - averageMs), + 0 + ) + const slope = Math.max(0, covariance / variance) + const fixedMs = Math.max(0, averageMs - slope * averageRecords) + const latest = samples[samples.length - 1] + // React to a newly expensive page immediately, even when the rolling fit + // still contains cheap pages. Never subtract more than its observed cost. + return { perRecordMs: Math.max(slope, (latest[phase] - fixedMs) / latest.records), fixedMs } +} + /** Per-copy work budget. Bytes alone cannot bound network-backed proof checks. */ export class SyncPageBudget { private maxItems = 64 + private samples: PageCost[] = [] + private proofs?: boolean + private fixedReadMs = 0 + private fixedCommitMs = 0 + private pagesSinceProbe = 0 apply(args: RequestSyncChunkArgs): RequestSyncChunkArgs { return { ...args, maxItems: Math.min(args.maxItems, this.maxItems) } } - committed(chunk: SyncChunk, elapsedMs: number): void { - if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return - const records = Object.values(chunk).reduce((count, value) => - count + (Array.isArray(value) ? value.length : 0), 0) + /** + * `elapsedMs` includes read and commit; an optional read measurement keeps + * network overhead separate from destination work. Old callers remain valid. + */ + committed(chunk: SyncChunk, elapsedMs: number, readMs = 0): void { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0 || !Number.isFinite(readMs) || readMs < 0 || readMs > elapsedMs) + return + const records = Object.values(chunk).reduce( + (count, value) => count + (Array.isArray(value) ? value.length : 0), + 0 + ) if (records === 0) return - // Leave headroom under the authentication deadline and limit growth when - // moving from cheap metadata to network-backed proof validation. - const ceiling = (chunk.provenTxs?.length ?? 0) > 0 ? 128 : 1000 - const suggested = Math.floor(records * 5000 / Math.max(1, elapsedMs)) - this.maxItems = Math.max(1, Math.min(ceiling, this.maxItems * 2, suggested)) + const proofs = (chunk.provenTxs?.length ?? 0) > 0 + // Metadata throughput does not predict network-backed proof checks. + if (proofs !== this.proofs) { + this.samples = [] + this.fixedReadMs = 0 + this.fixedCommitMs = 0 + this.pagesSinceProbe = 0 + } + this.proofs = proofs + this.samples.push({ records, readMs, commitMs: elapsedMs - readMs }) + if (this.samples.length > 6) this.samples.shift() + const ceiling = proofs ? 128 : 1000 + const read = marginalCost(this.samples, 'readMs', this.fixedReadMs) + const commit = marginalCost(this.samples, 'commitMs', this.fixedCommitMs) + this.fixedReadMs = read.fixedMs + this.fixedCommitMs = commit.fixedMs + const perRecordMs = read.perRecordMs + commit.perRecordMs + const suggested = Math.floor(5000 / Math.max(0.001, perRecordMs)) + let next = Math.max(1, Math.min(ceiling, this.maxItems * 2, suggested)) + this.pagesSinceProbe++ + // At the one-record floor there is no size variation from which to learn + // fixed latency. A bounded two-record probe prevents permanent collapse. + // A genuinely expensive proof returns to one on the next measurement. + if (next === 1 && this.maxItems === 1 && this.pagesSinceProbe >= 4) next = 2 + if (next > this.maxItems) this.pagesSinceProbe = 0 + this.maxItems = next } } diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts b/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts new file mode 100644 index 000000000..c657d8b46 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts @@ -0,0 +1,44 @@ +import type { RequestSyncChunkArgs, SyncChunk } from '../../sdk/WalletStorage.interfaces' +import { WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' + +/** Detach flat sync records, dates and byte arrays without a JSON-sized intermediate string. */ +function recordCopy(record: T): T { + const result = { ...record } + for (const key of Object.keys(result) as Array) { + const value = result[key] + let copy: unknown = value + if (Array.isArray(value)) copy = value.slice() + else if (value instanceof Uint8Array) copy = value.slice() + else if (value != null && typeof value === 'object') { + // Date's internal-slot check also accepts dates returned by an IndexedDB + // implementation in another realm; arbitrary nested objects are invalid. + try { + copy = new Date(Date.prototype.getTime.call(value)) + } catch { + throw new WERR_INVALID_PARAMETER('chunk', 'flat table records with dates and byte arrays') + } + } + result[key] = copy as T[keyof T] + } + return result +} + +export function snapshotSyncPage( + args: RequestSyncChunkArgs, + chunk: SyncChunk +): { args: RequestSyncChunkArgs; chunk: SyncChunk } { + const snapshot = { ...chunk } + for (const key of Object.keys(snapshot) as Array) { + const value = snapshot[key] + if (Array.isArray(value)) Object.assign(snapshot, { [key]: value.map(record => recordCopy(record)) }) + } + if (chunk.user != null) snapshot.user = recordCopy(chunk.user) + return { + args: { + ...args, + since: args.since == null ? undefined : new Date(args.since), + offsets: args.offsets.map(offset => ({ ...offset })) + }, + chunk: snapshot + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts index 8aaf2c7bc..d205e2dbd 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts @@ -42,5 +42,12 @@ export function validateSyncCheckpoint(value: SyncCheckpoint, previous?: Partial if (!Number.isFinite(since.getTime())) invalid() } if (previous?.since != null && (since == null || since < previous.since)) invalid() + if ( + previous != null && + since?.getTime() === previous.since?.getTime() && + previous.offsets != null && + offsets.some((entry, index) => entry.offset < (previous.offsets?.[index]?.offset ?? 0)) + ) + invalid() return { syncStateId: value.syncStateId, since, offsets } } diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.test.ts new file mode 100644 index 000000000..2e08d28f1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.test.ts @@ -0,0 +1,128 @@ +import 'fake-indexeddb/auto' +import { randomUUID } from 'node:crypto' +import { PrivateKey } from '@bsv/sdk' +import { WalletError } from '../../sdk/WalletError' +import { WalletErrorFromJson } from '../../sdk/WalletErrorFromJson' +import { WERR_INTERNAL, WERR_NETWORK_CHAIN } from '../../sdk/WERR_errors' +import type { Chain } from '../../sdk/types' +import type { ProcessSyncChunkResult } from '../../sdk/WalletStorage.interfaces' +import { StorageIdb } from '../StorageIdb' +import { StorageProvider } from '../StorageProvider' +import { WalletStorageManager } from '../WalletStorageManager' +import { throwSyncResultError } from './syncFailure' + +const stores: StorageIdb[] = [] +const identityKey = PrivateKey.fromRandom().toPublicKey().toString() + +async function makeStorage(chain: Chain = 'test'): Promise { + const storage = new StorageIdb(StorageProvider.createStorageBaseOptions(chain)) + storage.dbName = `sync-failure-${randomUUID()}` + stores.push(storage) + await storage.migrate('sync failure fixture', PrivateKey.fromRandom().toPublicKey().toString()) + await storage.makeAvailable() + return storage +} + +afterEach(async () => { + jest.restoreAllMocks() + for (const storage of stores.splice(0)) { + await storage.destroy() + await storage.dropAllData() + } +}) + +describe.each(['to', 'from'] as const)('sync %s failure contracts', direction => { + function copy(reader: StorageIdb, writer: StorageIdb): Promise { + const manager = new WalletStorageManager(identityKey, direction === 'to' ? reader : writer) + return direction === 'to' + ? manager.syncToWriter({ identityKey }, writer) + : manager.syncFromReader(identityKey, reader) + } + + test.each(['main', 'stn', 'ttn', 'tstn', 'mock'])( + 'rejects test/%s before user registration, checkpoint creation or a chunk read', + async chain => { + const reader = await makeStorage(chain) + const writer = await makeStorage() + const read = jest.spyOn(reader, 'getSyncChunk') + const process = jest.spyOn(writer, 'processSyncChunk') + const register = jest.spyOn(writer, 'findOrInsertUser') + await expect(copy(reader, writer)).rejects.toBeInstanceOf(WERR_NETWORK_CHAIN) + expect(read).not.toHaveBeenCalled() + expect(process).not.toHaveBeenCalled() + expect(register).not.toHaveBeenCalled() + expect(await writer.countUsers({ partial: {} })).toBe(0) + expect(await writer.countSyncStates({ partial: {} })).toBe(0) + } + ) + + test.each([undefined, 'invalid'])('does not guess an absent or unrecognized chain: %s', async chain => { + const reader = await makeStorage() + const writer = await makeStorage() + jest.spyOn(reader, 'makeAvailable').mockResolvedValue({ ...reader.getSettings(), chain } as never) + await expect(copy(reader, writer)).rejects.toBeInstanceOf(WERR_NETWORK_CHAIN) + expect(await writer.countUsers({ partial: {} })).toBe(0) + expect(await writer.countSyncStates({ partial: {} })).toBe(0) + }) + + test.each(['returned', 'serialized', 'thrown'] as const)( + 'preserves a %s failure and resumes only from the durable checkpoint', + async mode => { + const reader = await makeStorage() + const writer = await makeStorage() + const { user } = await reader.findOrInsertUser(identityKey) + await reader.findOrInsertTxLabel(user.userId, 'retained across failure') + const settings = reader.getSettings() + const before = await writer.getSyncCheckpoint({ identityKey }, settings.storageIdentityKey, settings.storageName) + const original = new WERR_NETWORK_CHAIN('custom provider rejected the page') + const error = mode === 'serialized' ? JSON.parse(WalletError.unknownToJson(original)) : original + const process = jest.spyOn(writer, 'processSyncChunk').mockImplementationOnce(async () => { + if (mode === 'thrown') throw error + return { + error, + done: true, + inserts: 999, + updates: 999, + maxUpdated_at: new Date(), + nextCheckpoint: { syncStateId: -1 } + } as ProcessSyncChunkResult + }) + try { + await copy(reader, writer) + throw new Error('sync ignored the provider failure') + } catch (caught) { + expect(caught).toBeInstanceOf(WERR_NETWORK_CHAIN) + expect((caught as Error).message).toBe(original.message) + if (mode !== 'serialized') expect(caught).toBe(original) + } + expect(process).toHaveBeenCalledTimes(1) + expect( + await writer.getSyncCheckpoint({ identityKey }, settings.storageIdentityKey, settings.storageName) + ).toEqual(before) + expect(await writer.countTxLabels({ partial: {} })).toBe(0) + process.mockRestore() + await copy(reader, writer) + expect(await writer.countTxLabels({ partial: {} })).toBe(1) + await copy(reader, writer) + expect(await writer.countTxLabels({ partial: {} })).toBe(1) + } + ) +}) + +test('checks all managed chains before registering a user on any store', async () => { + const active = await makeStorage('main') + const backup = await makeStorage('test') + const manager = new WalletStorageManager(identityKey, active, [backup]) + await expect(manager.makeAvailable()).rejects.toBeInstanceOf(WERR_NETWORK_CHAIN) + expect(await active.countUsers({ partial: {} })).toBe(0) + expect(await backup.countUsers({ partial: {} })).toBe(0) +}) + +test('preserves normal errors, rejects malformed returned failures, and retains JSON identity', () => { + const original = new Error('custom failure') + expect(() => throwSyncResultError({ error: original } as ProcessSyncChunkResult)).toThrow(original) + expect(() => throwSyncResultError({ error: 'malformed' } as never)).toThrow(WERR_INTERNAL) + expect(() => throwSyncResultError({} as ProcessSyncChunkResult)).not.toThrow() + const encoded = WalletError.unknownToJson(new WERR_NETWORK_CHAIN()) + expect(WalletErrorFromJson(JSON.parse(encoded))).toBeInstanceOf(WERR_NETWORK_CHAIN) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.ts new file mode 100644 index 000000000..ba6886281 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.ts @@ -0,0 +1,35 @@ +import { WalletErrorFromJson } from '../../sdk/WalletErrorFromJson' +import { WERR_INVALID_OPERATION, WERR_NETWORK_CHAIN } from '../../sdk/WERR_errors' +import type { RequestSyncChunkArgs } from '../../sdk/WalletStorage.interfaces' +import type { ProcessSyncChunkResult } from '../../sdk/WalletStorage.interfaces' +import type { TableSettings } from '../schema/tables' + +const supportedChains = new Set(['main', 'test', 'stn', 'ttn', 'tstn', 'mock']) + +/** Read-only preflight, before user registration or a destination checkpoint is created. */ +export function assertSyncNetwork(reader: TableSettings, writer: TableSettings): void { + if (!supportedChains.has(reader.chain) || !supportedChains.has(writer.chain) || reader.chain !== writer.chain) { + throw new WERR_NETWORK_CHAIN('Live sync requires matching declared network chains on both storage providers.') + } +} + +/** + * A returned failure has precedence over counters, completion and checkpoint hints. + * Shipped providers normally throw; external providers may use the public error field. + */ +export function throwSyncResultError(result: ProcessSyncChunkResult): void { + const error = result.error + if (error == null) return + if (error instanceof Error) throw error + throw WalletErrorFromJson(error) +} + +/** An unfinished page must advance durable progress, never spin on a provider's empty success. */ +export function assertSyncProgress(before: RequestSyncChunkArgs, after: RequestSyncChunkArgs): void { + const sameSince = before.since?.getTime() === after.since?.getTime() + if (sameSince && after.offsets.every((entry, index) => entry.offset === before.offsets[index]?.offset)) { + throw new WERR_INVALID_OPERATION( + 'Sync provider reported an unfinished page without advancing its durable checkpoint.' + ) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts new file mode 100644 index 000000000..aabad6ba9 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts @@ -0,0 +1,405 @@ +import 'fake-indexeddb/auto' +import { randomUUID } from 'node:crypto' +import { PrivateKey } from '@bsv/sdk' +import { StorageIdb } from '../StorageIdb' +import { StorageProvider } from '../StorageProvider' +import { WalletStorageManager } from '../WalletStorageManager' +import type { SyncSessionProgress } from './syncSession' + +const stores: StorageIdb[] = [] +const identityKey = PrivateKey.fromRandom().toPublicKey().toString() + +function deferred() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +async function makeStorage(dbName?: string): Promise { + const storage = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + storage.dbName = dbName ?? `sync-session-${randomUUID()}` + stores.push(storage) + await storage.migrate('sync session fixture', PrivateKey.fromRandom().toPublicKey().toString()) + await storage.makeAvailable() + return storage +} + +async function fixture(rows = 25) { + const reader = await makeStorage() + const writer = await makeStorage() + const { user } = await reader.findOrInsertUser(identityKey) + for (let i = 0; i < rows; i++) await reader.findOrInsertTxLabel(user.userId, `source label ${i}`) + const manager = new WalletStorageManager(identityKey, writer) + await manager.makeAvailable() + return { reader, writer, manager } +} + +afterEach(async () => { + jest.restoreAllMocks() + for (const storage of stores) await storage.destroy() + for (const storage of stores.splice(0)) await storage.dropAllData() +}) + +test('foreground reads and writes complete while source I/O waits, with bounded pages and durable progress', async () => { + const { reader, writer, manager } = await fixture() + const entered = deferred() + const release = deferred() + const read = reader.getSyncChunk.bind(reader) + const reads = jest.spyOn(reader, 'getSyncChunk').mockImplementationOnce(async args => { + const chunk = await read(args) + entered.resolve() + await release.promise + return chunk + }) + const events: SyncSessionProgress[] = [] + const sync = manager.syncFromReaderResumable(identityKey, reader, { + maxItems: 3, + maxRoughSize: 4096, + onProgress: event => events.push(event) + }) + await entered.promise + try { + const userId = await manager.getUserId() + await manager.runAsWriter(async () => { + await writer.findOrInsertTxLabel(userId, 'foreground write') + }) + expect(await manager.runAsReader(async () => await writer.countTxLabels({ partial: { userId } }))).toBe(1) + } finally { + release.resolve() + } + const result = await sync + expect(result).toMatchObject({ status: 'completed', mode: 'paged' }) + expect(result.pages).toBeGreaterThan(8) + expect(reads.mock.calls.every(([args]) => args.maxItems <= 3 && args.maxRoughSize <= 4096)).toBe(true) + expect(events.filter(event => event.state === 'committed')).toHaveLength(result.pages) + expect(events.every(event => event.mode === 'paged')).toBe(true) + expect(await writer.countTxLabels({ partial: {} })).toBe(26) + expect((await manager.syncFromReaderResumable(identityKey, reader)).inserts).toBe(0) +}) + +test('cancellation during a read discards the late page without starting a write', async () => { + const { reader, writer, manager } = await fixture() + const entered = deferred() + const release = deferred() + const read = reader.getSyncChunk.bind(reader) + jest.spyOn(reader, 'getSyncChunk').mockImplementationOnce(async args => { + const chunk = await read(args) + entered.resolve() + await release.promise + return chunk + }) + const process = jest.spyOn(writer, 'prepareSyncChunk') + const controller = new AbortController() + const sync = manager.syncFromReaderResumable(identityKey, reader, { signal: controller.signal }) + await entered.promise + controller.abort() + release.resolve() + expect(await sync).toMatchObject({ status: 'cancelled', pages: 0, inserts: 0 }) + expect(process).not.toHaveBeenCalled() + expect(await writer.countTxLabels({ partial: {} })).toBe(0) +}) + +test('cancellation waits for a committed page acknowledgement and a reopened manager resumes it', async () => { + const { reader, writer, manager } = await fixture() + const entered = deferred() + const release = deferred() + const prepare = writer.prepareSyncChunk.bind(writer) + const spy = jest.spyOn(writer, 'prepareSyncChunk').mockImplementationOnce(async (args, chunk) => { + const commit = await prepare(args, chunk) + return async () => { + const result = await commit() + entered.resolve() + await release.promise + return result + } + }) + const controller = new AbortController() + const states: string[] = [] + let settled = false + const sync = manager + .syncFromReaderResumable(identityKey, reader, { + maxItems: 3, + signal: controller.signal, + onProgress: event => states.push(event.state) + }) + .then(result => { + settled = true + return result + }) + await entered.promise + controller.abort() + await Promise.resolve() + expect(settled).toBe(false) + release.resolve() + const stopped = await sync + expect(stopped).toMatchObject({ status: 'cancelled', pages: 1 }) + expect(stopped.checkpoint?.offsets.some(entry => entry.offset > 0)).toBe(true) + expect(states.slice(-3)).toEqual(['committed', 'cancelling', 'cancelled']) + spy.mockRestore() + await writer.destroy() + const reopened = await makeStorage(writer.dbName) + const resumed = new WalletStorageManager(identityKey, reopened) + expect(await resumed.syncFromReaderResumable(identityKey, reader, { maxItems: 3 })).toMatchObject({ + status: 'completed' + }) + expect(await reopened.countTxLabels({ partial: {} })).toBe(25) + expect(await resumed.syncFromReaderResumable(identityKey, reader)).toMatchObject({ inserts: 0, updates: 0 }) +}) + +test('a lost acknowledgement is not retried; restart loads the committed destination checkpoint', async () => { + const { reader, writer, manager } = await fixture() + const prepare = writer.prepareSyncChunk.bind(writer) + const lost = new Error('lost acknowledgement after commit') + const spy = jest.spyOn(writer, 'prepareSyncChunk').mockImplementationOnce(async (args, chunk) => { + const commit = await prepare(args, chunk) + return async () => { + await commit() + throw lost + } + }) + await expect(manager.syncFromReaderResumable(identityKey, reader, { maxItems: 3 })).rejects.toBe(lost) + expect(spy).toHaveBeenCalledTimes(1) + spy.mockRestore() + const resumed = new WalletStorageManager(identityKey, writer) + await resumed.syncFromReaderResumable(identityKey, reader, { maxItems: 3 }) + expect(await writer.countTxLabels({ partial: {} })).toBe(25) +}) + +test('primary replacement fences a late source reply before it mutates the old writer', async () => { + const { reader, writer } = await fixture() + const replacement = await makeStorage() + const manager = new WalletStorageManager(identityKey, writer, [replacement]) + await manager.makeAvailable() + await manager.setActive(writer.getSettings().storageIdentityKey) + const entered = deferred() + const release = deferred() + const read = reader.getSyncChunk.bind(reader) + jest.spyOn(reader, 'getSyncChunk').mockImplementationOnce(async args => { + const chunk = await read(args) + entered.resolve() + await release.promise + return chunk + }) + const prepare = writer.prepareSyncChunk.bind(writer) + const process = jest.fn() + jest.spyOn(writer, 'prepareSyncChunk').mockImplementation(async (args, chunk) => { + const commit = await prepare(args, chunk) + return async () => { + process() + return await commit() + } + }) + const sync = manager.syncFromReaderResumable(identityKey, reader) + const rejected = expect(sync).rejects.toThrow('destination generation changed') + await entered.promise + try { + await manager.setActive(replacement.getSettings().storageIdentityKey) + } finally { + release.resolve() + } + await rejected + expect(process).not.toHaveBeenCalled() + expect(await writer.countTxLabels({ partial: {} })).toBe(0) + expect(await replacement.countTxLabels({ partial: {} })).toBe(0) +}) + +test('concurrent sessions cannot commit the same checkpoint twice', async () => { + const { reader, writer, manager } = await fixture() + const entered = deferred() + const release = deferred() + const read = reader.getSyncChunk.bind(reader) + let reads = 0 + jest.spyOn(reader, 'getSyncChunk').mockImplementation(async args => { + const chunk = await read(args) + if (++reads <= 2) { + if (reads === 2) entered.resolve() + await release.promise + } + return chunk + }) + const copies = Promise.allSettled([ + manager.syncFromReaderResumable(identityKey, reader, { maxItems: 3 }), + manager.syncFromReaderResumable(identityKey, reader, { maxItems: 3 }) + ]) + await entered.promise + release.resolve() + const outcomes = await copies + expect(outcomes.filter(result => result.status === 'fulfilled')).toHaveLength(1) + const failure = outcomes.find(result => result.status === 'rejected') as PromiseRejectedResult + expect(failure.reason.message).toContain('checkpoint changed') + expect(await writer.countTxLabels({ partial: {} })).toBe(25) +}) + +test('legacy destination capabilities retain exclusive execution and checkpoint fallback', async () => { + const { reader, writer } = await fixture() + jest.spyOn(writer, 'getCapabilities').mockResolvedValue({}) + jest.spyOn(writer, 'getSyncCheckpoint').mockResolvedValue(undefined as never) + const process = writer.processSyncChunk.bind(writer) + jest.spyOn(writer, 'processSyncChunk').mockImplementation(async (args, chunk) => { + const { nextCheckpoint: _, ...result } = await process({ ...args, includeNextCheckpoint: false }, chunk) + return result + }) + const manager = new WalletStorageManager(identityKey, writer) + const entered = deferred() + const release = deferred() + const read = reader.getSyncChunk.bind(reader) + jest.spyOn(reader, 'getSyncChunk').mockImplementationOnce(async args => { + entered.resolve() + await release.promise + return await read(args) + }) + const sync = manager.syncFromReaderResumable(identityKey, reader, { maxItems: 4 }) + await entered.promise + let foreground = false + const queued = manager.runAsReader(async () => { + foreground = true + }) + await Promise.resolve() + expect(foreground).toBe(false) + release.resolve() + expect(await sync).toMatchObject({ status: 'completed', mode: 'exclusive' }) + await queued + expect(foreground).toBe(true) + expect(await writer.countTxLabels({ partial: {} })).toBe(25) +}) + +test('progress observers cannot corrupt checkpoint state and unfinished zero-progress pages terminate', async () => { + const { reader, writer, manager } = await fixture() + await manager.syncFromReaderResumable(identityKey, reader, { + maxItems: 3, + onProgress: event => { + if (event.checkpoint != null) event.checkpoint.offsets[0].offset = 999999 + } + }) + expect(await writer.countTxLabels({ partial: {} })).toBe(25) + const process = jest + .spyOn(writer, 'prepareSyncChunk') + .mockResolvedValue(async () => ({ done: false, inserts: 0, updates: 0 })) + await expect(manager.syncFromReaderResumable(identityKey, reader)).rejects.toThrow('without advancing') + expect(process).toHaveBeenCalledTimes(1) +}) + +test.each([true, false])('manager concurrent reads follow the provider promise (%s)', async concurrent => { + const { writer } = await fixture(0) + const capabilities = writer.getCapabilities.bind(writer) + jest.spyOn(writer, 'getCapabilities').mockImplementation(async () => ({ + ...(await capabilities()), + storageAccess: { version: 1, concurrentReads: concurrent, atomicSyncPages: true } + })) + const manager = new WalletStorageManager(identityKey, writer) + await manager.makeAvailable() + const entered = deferred() + const release = deferred() + const first = manager.runAsReader(async () => { + entered.resolve() + await release.promise + }) + await entered.promise + let secondEntered = false + const second = manager.runAsReader(async () => { + secondEntered = true + }) + await new Promise(resolve => setImmediate(resolve)) + expect(secondEntered).toBe(concurrent) + release.resolve() + await Promise.all([first, second]) +}) + +test('a queued page cancelled behind foreground work never starts its commit', async () => { + const { reader, writer, manager } = await fixture() + const locked = deferred() + const release = deferred() + const controller = new AbortController() + const prepare = writer.prepareSyncChunk.bind(writer) + const applied = jest.fn() + jest.spyOn(writer, 'prepareSyncChunk').mockImplementation(async (args, chunk) => { + const apply = await prepare(args, chunk) + return async () => { + applied() + return await apply() + } + }) + let foreground: Promise | undefined + const sync = manager.syncFromReaderResumable(identityKey, reader, { + signal: controller.signal, + onProgress: event => { + if (event.state === 'committing') + foreground = manager.runAsWriter(async () => { + locked.resolve() + await release.promise + }) + } + }) + await locked.promise + controller.abort() + release.resolve() + await foreground + expect(await sync).toMatchObject({ status: 'cancelled', pages: 0 }) + expect(applied).not.toHaveBeenCalled() + expect(await writer.countTxLabels({ partial: {} })).toBe(0) +}) + +test.each(['fromStorageIdentityKey', 'toStorageIdentityKey', 'userIdentityKey'] as const)( + 'rejects a page with the wrong %s before preparation', + async key => { + const { reader, writer, manager } = await fixture() + const get = reader.getSyncChunk.bind(reader) + jest + .spyOn(reader, 'getSyncChunk') + .mockImplementation(async args => ({ ...(await get(args)), [key]: 'wrong identity' })) + const prepare = jest.spyOn(writer, 'prepareSyncChunk') + await expect(manager.syncFromReaderResumable(identityKey, reader)).rejects.toThrow('bound to this sync') + expect(prepare).not.toHaveBeenCalled() + expect(await writer.countTxLabels({ partial: {} })).toBe(0) + } +) + +test.each([{ maxItems: 0 }, { maxItems: 1001 }, { maxRoughSize: Infinity }, { maxRoughSize: 1.5 }])( + 'rejects invalid bounds before fetching a page: %o', + async options => { + const { reader, writer, manager } = await fixture() + const read = jest.spyOn(reader, 'getSyncChunk') + await expect(manager.syncFromReaderResumable(identityKey, reader, options)).rejects.toThrow('an integer from') + expect(read).not.toHaveBeenCalled() + expect(await writer.countSyncStates({ partial: {} })).toBe(0) + } +) + +test('IndexedDB indexed paging retains filtered offset and timestamp semantics', async () => { + const { reader } = await fixture(100) + const { user } = await reader.findOrInsertUser(identityKey) + const all = await reader.findTxLabels({ partial: { userId: user.userId } }) + const page = await reader.findTxLabels({ partial: { userId: user.userId }, paged: { offset: 80, limit: 5 } }) + expect(page).toEqual(all.slice(80, 85)) + await reader.updateTxLabel(all[85].txLabelId, { isDeleted: true, updated_at: new Date('2099-01-01') }) + expect( + await reader.findTxLabels({ partial: { userId: user.userId, isDeleted: true }, paged: { offset: 1, limit: 5 } }) + ).toEqual([]) + expect( + await reader.findTxLabels({ + partial: { userId: user.userId }, + since: new Date('2098-01-01'), + paged: { offset: 1, limit: 5 } + }) + ).toEqual([]) + expect( + await reader.findTxLabels({ + partial: { userId: user.userId }, + since: new Date('2098-01-01'), + paged: { offset: 0, limit: 5 } + }) + ).toMatchObject([{ txLabelId: all[85].txLabelId }]) +}) + +test.each([undefined, 10000000])( + 'uses a bounded new default without narrowing the explicit legacy ceiling (%s)', + async maximum => { + const { reader, manager } = await fixture(3) + const calls = jest.spyOn(reader, 'getSyncChunk') + await manager.syncFromReaderResumable(identityKey, reader, { maxRoughSize: maximum }) + expect(calls.mock.calls.length).toBeGreaterThan(0) + expect(calls.mock.calls.every(([args]) => args.maxRoughSize === (maximum ?? 262144))).toBe(true) + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts new file mode 100644 index 000000000..d3ac3c3dd --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts @@ -0,0 +1,165 @@ +import type { + ProcessSyncChunkResult, + RequestSyncChunkArgs, + SyncCheckpoint, + SyncChunk, + WalletStorageSync, + WalletStorageSyncReader +} from '../../sdk/WalletStorage.interfaces' +import { WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' +import { SyncPageBudget } from './SyncPageBudget' +import { validateSyncCheckpoint } from './syncCheckpoint' +import { assertSyncProgress, throwSyncResultError } from './syncFailure' + +export interface SyncSessionOptions { + /** Cancellation waits for an in-flight commit acknowledgement; it never rolls back an acknowledged page. */ + signal?: AbortSignal + /** Additional page ceilings; defaults are 1,000 rows and 262,144 rough encoded bytes. */ + maxItems?: number + maxRoughSize?: number + /** Called outside page ownership in paged mode. Checkpoints are independent copies. */ + onProgress?: (progress: SyncSessionProgress) => void +} + +export interface SyncSessionProgress { + state: 'reading' | 'preparing' | 'committing' | 'committed' | 'cancelling' | 'cancelled' | 'completed' + mode: 'paged' | 'exclusive' + pages: number + inserts: number + updates: number + checkpoint?: SyncCheckpoint + readMs?: number + prepareMs?: number + queueMs?: number + commitMs?: number +} + +export interface SyncSessionResult { + status: 'completed' | 'cancelled' + mode: 'paged' | 'exclusive' + pages: number + inserts: number + updates: number + checkpoint?: SyncCheckpoint +} + +interface PullSession { + reader: WalletStorageSyncReader + writer: WalletStorageSync + mode: 'paged' | 'exclusive' + activeStorage: string + atomicCheckpoint: boolean + loadRequest: () => Promise + prepare?: (args: RequestSyncChunkArgs, chunk: SyncChunk) => Promise<() => Promise> + /** Must check destination/generation inside ownership immediately before any write. */ + commit: (operation: () => Promise) => Promise +} + +function boundedOption(value: number | undefined, name: string, maximum: number): number { + if (value === undefined) return maximum + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new WERR_INVALID_PARAMETER(name, `an integer from 1 to ${maximum}`) + } + return value +} + +function requestCheckpoint(args: RequestSyncChunkArgs): SyncCheckpoint { + return validateSyncCheckpoint({ syncStateId: args.syncStateId ?? 0, since: args.since, offsets: args.offsets }) +} + +/** One page in flight; resume always starts with the destination's durable checkpoint. */ +export async function runPullSession(session: PullSession, options: SyncSessionOptions): Promise { + const maxItems = boundedOption(options.maxItems, 'maxItems', 1000) + const maxRoughSize = boundedOption(options.maxRoughSize ?? 262144, 'maxRoughSize', 10000000) + const { signal, onProgress } = options + const result: SyncSessionResult = { status: 'completed', mode: session.mode, pages: 0, inserts: 0, updates: 0 } + const notify = (state: SyncSessionProgress['state'], timing: Partial = {}): void => { + onProgress?.({ + ...result, + ...timing, + state, + checkpoint: result.checkpoint == null ? undefined : validateSyncCheckpoint(result.checkpoint) + }) + } + const cancelled = (): boolean => { + if (signal?.aborted !== true) return false + result.status = 'cancelled' + notify('cancelling') + notify('cancelled') + return true + } + if (cancelled()) return result + let args = await session.commit(session.loadRequest) + result.checkpoint = requestCheckpoint(args) + args = { + ...args, + maxItems: Math.min(args.maxItems, maxItems), + maxRoughSize: Math.min(args.maxRoughSize, maxRoughSize) + } + const budget = new SyncPageBudget() + for (;;) { + if (cancelled()) return result + const pageArgs = { + ...budget.apply(args), + includeNextCheckpoint: true, + requireMatchingCheckpoint: session.atomicCheckpoint + } + notify('reading') + if (cancelled()) return result + const readAt = Date.now() + const chunk = await session.reader.getSyncChunk(pageArgs) + const readMs = Date.now() - readAt + if (cancelled()) return result + if ( + chunk.fromStorageIdentityKey !== pageArgs.fromStorageIdentityKey || + chunk.toStorageIdentityKey !== pageArgs.toStorageIdentityKey || + chunk.userIdentityKey !== pageArgs.identityKey + ) { + throw new WERR_INVALID_PARAMETER('chunk', 'bound to this sync source, destination and wallet identity') + } + if (chunk.user != null) chunk.user.activeStorage = session.activeStorage + notify('preparing', { readMs }) + if (cancelled()) return result + const prepareAt = Date.now() + const apply = + session.prepare == null + ? async () => await session.writer.processSyncChunk(pageArgs, chunk) + : await session.prepare(pageArgs, chunk) + const prepareMs = Date.now() - prepareAt + if (cancelled()) return result + notify('committing', { readMs }) + const queuedAt = Date.now() + let commitAt = queuedAt + const committed = await session.commit(async () => { + // A cancellation requested while queued must not start a destination write. + if (signal?.aborted === true) return undefined + commitAt = Date.now() + const reply = await apply() + throwSyncResultError(reply) + const checkpoint = + reply.nextCheckpoint == null + ? requestCheckpoint(await session.loadRequest()) + : validateSyncCheckpoint(reply.nextCheckpoint, reply.done ? { syncStateId: args.syncStateId } : args) + return { reply, checkpoint } + }) + if (committed == null) { + cancelled() + return result + } + const commitMs = Date.now() - commitAt + const { reply, checkpoint } = committed + if (!reply.done) assertSyncProgress(args, { ...args, ...checkpoint }) + budget.committed(chunk, readMs + prepareMs + commitMs, readMs + prepareMs) + result.pages++ + result.inserts += reply.inserts + result.updates += reply.updates + result.checkpoint = checkpoint + notify('committed', { readMs, prepareMs, queueMs: commitAt - queuedAt, commitMs }) + if (cancelled()) return result + if (reply.done) { + notify('completed') + return result + } + args = { ...args, ...checkpoint } + } +} diff --git a/packages/wallet/wallet-toolbox/test/storage/processAction.test.ts b/packages/wallet/wallet-toolbox/test/storage/processAction.test.ts index 09556bb4d..38c110f9a 100644 --- a/packages/wallet/wallet-toolbox/test/storage/processAction.test.ts +++ b/packages/wallet/wallet-toolbox/test/storage/processAction.test.ts @@ -3,7 +3,7 @@ import { processAction, shareReqsWithWorld } from '../../src/storage/methods/pro import { StorageProvider } from '../../src/storage/StorageProvider' import { TableProvenTxReq } from '../../src/storage/schema/tables/TableProvenTxReq' -function makeReadyReq (): TableProvenTxReq { +function makeReadyReq(): TableProvenTxReq { const now = new Date() return { created_at: now, @@ -20,7 +20,7 @@ function makeReadyReq (): TableProvenTxReq { } } -function makeStorageFake () { +function makeStorageFake() { return { transaction: jest.fn(async (callback: (trx?: unknown) => Promise) => await callback(undefined)), updateProvenTxReq: jest.fn(async () => 1), @@ -42,23 +42,29 @@ describe('processAction shareReqsWithWorld', () => { telemetry: new Telemetry({ sink: { capture: event => events.push(event) } }) } - const result = await processAction(storage as any, { userId: 1 }, { - isNewTx: false, - isSendWith: false, - isNoSend: true, - isDelayed: false, - sendWith: [] - }) + const result = await processAction( + storage as any, + { userId: 1 }, + { + isNewTx: false, + isSendWith: false, + isNoSend: true, + isDelayed: false, + sendWith: [] + } + ) expect(result.sendWithResults).toEqual([]) - expect(events).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'wallet.storage.process_action.share', spanStatus: 'ok' }), - expect.objectContaining({ - name: 'wallet.storage.process_action', - spanStatus: 'ok', - attributes: expect.objectContaining({ 'action.send_result_count': 0 }) - }) - ])) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'wallet.storage.process_action.share', spanStatus: 'ok' }), + expect.objectContaining({ + name: 'wallet.storage.process_action', + spanStatus: 'ok', + attributes: expect.objectContaining({ 'action.send_result_count': 0 }) + }) + ]) + ) }) test('preserves the non-instrumented processAction path when telemetry is disabled', async () => { @@ -67,13 +73,19 @@ describe('processAction shareReqsWithWorld', () => { telemetry: new Telemetry() } - await expect(processAction(storage as any, { userId: 1 }, { - isNewTx: false, - isSendWith: false, - isNoSend: true, - isDelayed: false, - sendWith: [] - })).resolves.toMatchObject({ sendWithResults: [] }) + await expect( + processAction( + storage as any, + { userId: 1 }, + { + isNewTx: false, + isSendWith: false, + isNoSend: true, + isDelayed: false, + sendWith: [] + } + ) + ).resolves.toMatchObject({ sendWithResults: [] }) }) test('delayed sends do not build aggregate BEEF before scheduling', async () => { @@ -90,7 +102,11 @@ describe('processAction shareReqsWithWorld', () => { const result = await shareReqsWithWorld(storage as any, 1, [req.txid], true) expect(storage.getReqsAndBeefToShareWithWorld).not.toHaveBeenCalled() - expect(storage.updateProvenTxReq).toHaveBeenCalledWith([req.provenTxReqId], expect.objectContaining({ status: 'unsent' }), undefined) + expect(storage.updateProvenTxReq).toHaveBeenCalledWith( + [req.provenTxReqId], + expect.objectContaining({ status: 'unsent' }), + undefined + ) expect(storage.updateTransaction).toHaveBeenCalledWith([22], { status: 'sending' }, undefined) expect(result.swr).toEqual([{ txid: req.txid, status: 'sending' }]) }) @@ -110,7 +126,11 @@ describe('processAction shareReqsWithWorld', () => { }) expect(beef.verify).not.toHaveBeenCalled() - expect(storage.updateProvenTxReq).toHaveBeenCalledWith([req.provenTxReqId], expect.objectContaining({ status: 'unsent' }), undefined) + expect(storage.updateProvenTxReq).toHaveBeenCalledWith( + [req.provenTxReqId], + expect.objectContaining({ status: 'unsent' }), + undefined + ) expect(storage.updateTransaction).toHaveBeenCalledWith([22], { status: 'sending' }, undefined) expect(result.swr).toEqual([{ txid: req.txid, status: 'sending' }]) }) @@ -176,23 +196,24 @@ describe('processAction shareReqsWithWorld', () => { const result = await storage.getReqsAndBeefToShareWithWorld([req.txid], []) - expect(result.details).toEqual([ - expect.objectContaining({ txid: req.txid, status: 'error' }) - ]) + expect(result.details).toEqual([expect.objectContaining({ txid: req.txid, status: 'error' })]) }) test('immediate sends still validate the aggregate BEEF before broadcasting', async () => { const req = makeReadyReq() const beef = { + bumps: [], verify: jest.fn(async () => false), toLogString: () => 'invalid beef' } as unknown as Beef const storage = makeStorageFake() - await expect(shareReqsWithWorld(storage as any, 1, [req.txid], false, { - beef, - details: [{ txid: req.txid, status: 'readyToSend', req }] - })).rejects.toThrow('merged Beef failed validation') + await expect( + shareReqsWithWorld(storage as any, 1, [req.txid], false, { + beef, + details: [{ txid: req.txid, status: 'readyToSend', req }] + }) + ).rejects.toThrow('merged Beef failed validation') expect(beef.verify).toHaveBeenCalled() expect(storage.attemptToPostReqsToNetwork).not.toHaveBeenCalled() From 1fe0a99930ede769805be2e995b0aedd920653a2 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 05:32:40 -0700 Subject: [PATCH 003/127] fix(messagebox): accept bounded canonical base64 PeerPay transactions --- .../messaging/message-box-client/CHANGELOG.md | 8 +++ .../messaging/message-box-client/README.md | 8 +++ .../messaging/message-box-client/package.json | 2 +- .../message-box-client/src/PeerPayClient.ts | 10 +++- .../src/Utils/peerPayTransaction.ts | 28 +++++++++ .../PeerClients.validation.security.test.ts | 59 ++++++++++++++++++- .../src/__tests/peerPayTransaction.test.ts | 52 ++++++++++++++++ 7 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 packages/messaging/message-box-client/src/Utils/peerPayTransaction.ts create mode 100644 packages/messaging/message-box-client/src/__tests/peerPayTransaction.test.ts diff --git a/packages/messaging/message-box-client/CHANGELOG.md b/packages/messaging/message-box-client/CHANGELOG.md index 098e21c68..45299f520 100644 --- a/packages/messaging/message-box-client/CHANGELOG.md +++ b/packages/messaging/message-box-client/CHANGELOG.md @@ -13,6 +13,14 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### 2.6.0 candidate — bounded PeerPay receive interoperability + +- Accept canonical standard base64 transaction strings alongside existing byte + arrays and numeric-key JSON objects in list, live and indexed PeerPay receipt. +- Bound encoded/decoded sizes before conversion and retain wallet validation and + acknowledgement order. Sender encoding and relay limits are unchanged. +- Keep #548 relay/preflight and #503 durable refund/outcome milestones open. + ### 2.5.2 candidate — authenticated transport and payment hardening - Internalize notification payments with the configured originator before acknowledgment. diff --git a/packages/messaging/message-box-client/README.md b/packages/messaging/message-box-client/README.md index 2de48947e..106754de6 100644 --- a/packages/messaging/message-box-client/README.md +++ b/packages/messaging/message-box-client/README.md @@ -316,6 +316,14 @@ Its SDK peer accepts both historical `number[]` and binary Wallet Wire `Uint8Array` transaction results, while payment messages retain a portable JSON byte-array representation. Receipt remains compatible with pending messages whose typed-array bytes were already serialized as contiguous numeric keys. +PeerPay receipt also accepts the canonical standard-base64 Atomic BEEF string +described by BRC-29. This receive-only compatibility applies to inbox lists, +indexed reloads, and live payments; senders keep the existing portable JSON +byte array. Empty, unpadded, URL-safe, whitespace-bearing, noncanonical pad-bit, +and malformed base64 strings are rejected before wallet work. Both encodings +retain the 64 MiB decoded transaction ceiling; base64 length is checked before +decoding. This does not raise relay request limits or provide delivery-limit +discovery, pre-broadcast sizing, or a durable retry/refund journal. The same compatibility contract covers paid-message fees, batch delivery, token settlements, live-message fallback, and generic remittance transport. In a multi-recipient send, the quoted server delivery fee applies to every diff --git a/packages/messaging/message-box-client/package.json b/packages/messaging/message-box-client/package.json index 7dbdfb204..26aaad9bd 100644 --- a/packages/messaging/message-box-client/package.json +++ b/packages/messaging/message-box-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/message-box-client", - "version": "2.5.2", + "version": "2.6.0", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/messaging/message-box-client/src/PeerPayClient.ts b/packages/messaging/message-box-client/src/PeerPayClient.ts index 76367bc6b..d16638f95 100644 --- a/packages/messaging/message-box-client/src/PeerPayClient.ts +++ b/packages/messaging/message-box-client/src/PeerPayClient.ts @@ -37,6 +37,7 @@ import { type PeerMessage } from './types.js' import * as Logger from './Utils/logger.js' +import { decodePeerPayTransaction } from './Utils/peerPayTransaction.js' function hexToBytes(hex: string): number[] { if (!/^[0-9a-f]{64}$/.test(hex)) { @@ -143,13 +144,16 @@ function normalizePaymentParams(value: unknown): PaymentParams { return { recipient, amount: payment.amount } } -function normalizePaymentToken(value: unknown): PaymentToken { +function normalizePaymentToken(value: unknown, allowBase64 = false): PaymentToken { const token = dataRecord(value) const customInstructions = dataRecord(token?.customInstructions) if (token == null || customInstructions == null) { throw new TypeError('Incoming payment token is invalid') } - const transaction = normalizeBRC100ByteArray(token.transaction) + const transaction = + allowBase64 && typeof token.transaction === 'string' + ? decodePeerPayTransaction(token.transaction, MAX_PAYMENT_TRANSACTION_BYTES) + : normalizeBRC100ByteArray(token.transaction) if ( transaction == null || transaction.length === 0 || @@ -207,7 +211,7 @@ function paymentFromMessage(value: unknown): IncomingPayment | null { return { messageId: boundedMessageId(message.messageId), sender: canonicalIdentityKey(message.sender), - token: normalizePaymentToken(payment) + token: normalizePaymentToken(payment, true) } } catch { return null diff --git a/packages/messaging/message-box-client/src/Utils/peerPayTransaction.ts b/packages/messaging/message-box-client/src/Utils/peerPayTransaction.ts new file mode 100644 index 000000000..1cce68141 --- /dev/null +++ b/packages/messaging/message-box-client/src/Utils/peerPayTransaction.ts @@ -0,0 +1,28 @@ +import { base64ToArray } from '@bsv/sdk/primitives/utils' + +const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +/** BRC-29 receive boundary only: reject noncanonical or oversized text before decoding. */ +export function decodePeerPayTransaction(value: string, maximumBytes: number): number[] { + const invalid = (): never => { + throw new TypeError('Incoming payment transaction must be bounded canonical base64') + } + if ( + !Number.isSafeInteger(maximumBytes) || + maximumBytes < 1 || + value.length === 0 || + value.length % 4 !== 0 || + value.length > Math.ceil(maximumBytes / 3) * 4 + ) + invalid() + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0 + const decodedLength = (value.length / 4) * 3 - padding + if (decodedLength === 0 || decodedLength > maximumBytes) invalid() + let last = 0 + for (let index = 0; index < value.length - padding; index++) { + last = alphabet.indexOf(value[index]) + if (last < 0) invalid() + } + if ((padding === 2 && (last & 15) !== 0) || (padding === 1 && (last & 3) !== 0)) invalid() + return base64ToArray(value) +} diff --git a/packages/messaging/message-box-client/src/__tests/PeerClients.validation.security.test.ts b/packages/messaging/message-box-client/src/__tests/PeerClients.validation.security.test.ts index 4eeb5ae86..fdcbe95d6 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerClients.validation.security.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerClients.validation.security.test.ts @@ -1,4 +1,4 @@ -import { PrivateKey, type WalletInterface } from '@bsv/sdk' +import { PrivateKey, PublicKey, P2PKH, Transaction, type WalletInterface } from '@bsv/sdk' import { jest } from '@jest/globals' import { PeerPayClient } from '../PeerPayClient.js' import { PeerTokenClient } from '../PeerTokenClient.js' @@ -158,6 +158,63 @@ describe('peer payment boundary validation', () => { ) }) + it.each(['AQID', [1, 2, 3], { 0: 1, 1: 2, 2: 3 }])( + 'normalizes BRC-29 and deployed byte representations across list and live paths %#', + async transaction => { + const incoming = message({ ...paymentToken, transaction }) + jest.spyOn(client, 'listMessages').mockResolvedValue([incoming]) + expect((await client.listIncomingPayments())[0].token.transaction).toEqual([1, 2, 3]) + const listen = jest.spyOn(client, 'listenForLiveMessages').mockResolvedValue() + const onPayment = jest.fn() + await client.listenForLivePayments({ onPayment }) + listen.mock.calls[0][0].onMessage(incoming) + expect(onPayment).toHaveBeenCalledWith( + expect.objectContaining({ + token: expect.objectContaining({ transaction: [1, 2, 3] }) + }) + ) + } + ) + + it('reloads a base64 Atomic BEEF and validates the actual payment before internalization', async () => { + const tx = new Transaction() + tx.addOutput({ + satoshis: 1, + lockingScript: new P2PKH().lock(PublicKey.fromString(identityA).toHash()) + }) + const bytes = tx.toAtomicBEEF() + jest + .spyOn(client, 'listMessagesLite') + .mockResolvedValue([ + message({ ...paymentToken, transaction: Buffer.from(bytes).toString('base64') }) + ]) + mockWallet.internalizeAction = jest.fn().mockResolvedValue({ accepted: true }) + const acknowledge = jest.spyOn(client, 'acknowledgeMessage').mockResolvedValue('ok') + await client.acceptPayment({ messageId: 'message-1', sender: identityA, token: paymentToken }) + expect(mockWallet.internalizeAction).toHaveBeenCalledWith( + expect.objectContaining({ tx: bytes }), + undefined + ) + expect(acknowledge).toHaveBeenCalledTimes(1) + }) + + it.each(['AR==', 'AQJ=', ' AQID', 'AQID\n', '-_8=', 'AQI', ''])( + 'retains invalid base64 %j without wallet or acknowledgement work', + async transaction => { + jest + .spyOn(client, 'listMessagesLite') + .mockResolvedValue([message({ ...paymentToken, transaction })]) + mockWallet.internalizeAction = jest.fn() + const acknowledge = jest.spyOn(client, 'acknowledgeMessage') + await expect( + client.acceptPayment({ messageId: 'message-1', sender: identityA, token: paymentToken }) + ).rejects.toThrow('not present exactly once') + expect(mockWallet.getPublicKey).not.toHaveBeenCalled() + expect(mockWallet.internalizeAction).not.toHaveBeenCalled() + expect(acknowledge).not.toHaveBeenCalled() + } + ) + it('bounds every payment collection before parsing attacker-controlled rows', async () => { const oversized = Array.from({ length: 1_001 }, () => message(paymentToken)) const list = jest.spyOn(client, 'listMessages') diff --git a/packages/messaging/message-box-client/src/__tests/peerPayTransaction.test.ts b/packages/messaging/message-box-client/src/__tests/peerPayTransaction.test.ts new file mode 100644 index 000000000..f14d57b61 --- /dev/null +++ b/packages/messaging/message-box-client/src/__tests/peerPayTransaction.test.ts @@ -0,0 +1,52 @@ +import fc from 'fast-check' +import { decodePeerPayTransaction } from '../Utils/peerPayTransaction.js' + +describe('BRC-29 canonical base64 receive boundary', () => { + it.each([ + ['AQ==', [1]], + ['AQI=', [1, 2]], + ['AQID', [1, 2, 3]], + ['+/8=', [251, 255]], + ['AAAA', [0, 0, 0]] + ])('decodes shared wire vector %s', (wire, expected) => { + expect(decodePeerPayTransaction(wire as string, 3)).toEqual(expected) + }) + + it.each([ + '', + 'A', + 'AQ', + 'AQ=', + 'AQ===', + '=AAA', + 'A=AA', + 'AA=A', + '====', + 'AR==', + 'AQJ=', + 'AQ==\n', + ' AQ==', + 'AQ I', + '-_8=', + 'AQ==' + ])('rejects %j', wire => { + expect(() => decodePeerPayTransaction(wire, 3)).toThrow('canonical base64') + }) + + it('checks decoded length as well as encoded length, before allocation', () => { + expect(() => decodePeerPayTransaction('AQID', 2)).toThrow('canonical base64') + expect(() => decodePeerPayTransaction('AQIDAQ==', 3)).toThrow('canonical base64') + expect(decodePeerPayTransaction('AQI=', 2)).toEqual([1, 2]) + }) + + it('matches an independent RFC 4648 encoder over all byte values and lengths', () => { + fc.assert( + fc.property(fc.uint8Array({ minLength: 1, maxLength: 4096 }), bytes => { + expect( + decodePeerPayTransaction(Buffer.from(bytes).toString('base64'), bytes.length) + ).toEqual(Array.from(bytes)) + }), + { numRuns: 200 } + ) + }) +}) From ce80627597d4143e5dc030ae0bdcebccbd330840 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 05:32:40 -0700 Subject: [PATCH 004/127] docs(release): record next interoperability candidates and validation boundaries --- docs/conformance/contributing-vectors.md | 2 +- docs/conformance/vectors.md | 2 +- docs/guides/index.md | 13 ++ docs/packages/helpers/amountinator.md | 10 +- docs/packages/helpers/did-client.md | 10 +- docs/packages/helpers/fund-wallet.md | 10 +- docs/packages/helpers/simple.md | 10 +- docs/packages/helpers/wallet-helper.md | 10 +- docs/packages/messaging/message-box-client.md | 8 +- docs/packages/middleware/402-pay.md | 8 +- .../middleware/auth-express-middleware.md | 8 +- .../middleware/payment-express-middleware.md | 8 +- .../overlays/overlay-discovery-services.md | 10 +- docs/packages/overlays/overlay-express.md | 10 +- docs/packages/sdk/bsv-sdk.md | 10 +- docs/packages/wallet/wallet-toolbox-client.md | 8 +- docs/packages/wallet/wallet-toolbox-mobile.md | 8 +- docs/packages/wallet/wallet-toolbox.md | 8 +- docs/reference/package-api-migrations.md | 132 ++++++++--------- docs/reference/stack-facts.md | 38 ++--- governance/package-release-notes.json | 90 ++++++------ governance/repository-health/baselines.json | 38 ++--- packages/helpers/amountinator/README.md | 7 + packages/helpers/amountinator/package.json | 2 +- packages/helpers/bsv-wallet-helper/README.md | 138 ++++++++++-------- .../helpers/bsv-wallet-helper/package.json | 2 +- packages/helpers/did-client/README.md | 15 +- packages/helpers/did-client/package.json | 2 +- packages/helpers/fund-wallet/README.md | 7 + packages/helpers/fund-wallet/package.json | 2 +- packages/helpers/simple/README.md | 7 + packages/helpers/simple/package.json | 2 +- packages/middleware/402-pay/CHANGELOG.md | 5 + packages/middleware/402-pay/README.md | 16 +- packages/middleware/402-pay/package.json | 2 +- .../overlay-discovery-services/CHANGELOG.md | 4 + .../overlay-discovery-services/README.md | 7 + .../overlay-discovery-services/package.json | 2 +- .../overlays/overlay-express/CHANGELOG.md | 4 + packages/overlays/overlay-express/README.md | 7 + .../overlays/overlay-express/package.json | 2 +- 41 files changed, 408 insertions(+), 276 deletions(-) diff --git a/docs/conformance/contributing-vectors.md b/docs/conformance/contributing-vectors.md index 80cee3ae3..206b17b3b 100644 --- a/docs/conformance/contributing-vectors.md +++ b/docs/conformance/contributing-vectors.md @@ -84,7 +84,7 @@ conformance/vectors/ broadcast/*.json messaging/{authsocket,brc31/,message-box-http}.json overlay/{lookup,submit,topic-management}.json - payments/{brc121,brc29-payment-protocol}.json + payments/{brc118,brc121,brc29-payment-protocol}.json regressions/ (12 files — special format with regression.issue metadata) sdk/compat/bsm.json sdk/crypto/ (8 files: aes, ecdsa, ecies, hash160, hmac, ripemd160, sha256, signature) diff --git a/docs/conformance/vectors.md b/docs/conformance/vectors.md index e40b2b643..3285a3138 100644 --- a/docs/conformance/vectors.md +++ b/docs/conformance/vectors.md @@ -27,7 +27,7 @@ conformance/vectors/ broadcast/{arc-submit,merkle-path-validation,merkle-service}.json messaging/{authsocket,brc31/authrite-signature,message-box-http}.json overlay/{lookup,submit,topic-management}.json - payments/{brc121,brc29-payment-protocol}.json + payments/{brc118,brc121,brc29-payment-protocol}.json regressions/*.json (12 files) sdk/compat/bsm.json sdk/crypto/{aes,ecdsa,ecies,hash160,hmac,ripemd160,sha256,signature}.json diff --git a/docs/guides/index.md b/docs/guides/index.md index acaf6e6ad..1144c1bb4 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -58,6 +58,13 @@ creator, issuer, Payee, wallet, evidence, Delivery, and storage roles. **Time:** ~45 minutes | **Level:** Advanced +### 7. [BRC-118 Authenticated Multipart Payments](./brc118-payments.md) + +Carry larger BRC-105 payments with exact-byte authentication, bounded parsing, +receiver-first negotiation and preparation before broadcast. + +**Time:** ~25 minutes | **Level:** Advanced + ## Recommended Learning Path 1. Start with **Wallet-Aware App** if you're new to wallets and transactions @@ -78,3 +85,9 @@ creator, issuer, Payee, wallet, evidence, Delivery, and storage roles. **Want to implement a protocol?** See [Conformance Testing](../conformance/). **Looking for infrastructure examples?** Check [Infrastructure Components](../infrastructure/). + +## Resumable wallet synchronization + +[Sync reliability and proof recovery](wallet-sync-reliability.md) describes the +Toolbox 2.14 candidate API, cancellation, atomic checkpoints, foreground fairness, +proof recovery, benchmark scope and the next coherent-snapshot milestone. diff --git a/docs/packages/helpers/amountinator.md b/docs/packages/helpers/amountinator.md index 3b84955fc..e82eb97ed 100644 --- a/docs/packages/helpers/amountinator.md +++ b/docs/packages/helpers/amountinator.md @@ -3,10 +3,10 @@ id: pkg-amountinator title: '@bsv/amountinator' kind: package domain: helpers -version: '2.1.6' +version: '2.1.7' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/amountinator' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator' @@ -16,6 +16,10 @@ tags: [helpers, amounts, satoshis] # @bsv/amountinator +The unpublished 2.1.7 candidate refreshes the packed first-party dependency +ranges for the next wallet interoperability release; no independent API or wire +format changes are introduced. Adopt after the dependency graph is published. + > Satoshi/BSV/USD and multi-fiat currency conversion with exchange rate caching and wallet settings integration — convert between crypto (SATS, BSV) and 15+ fiat currencies with auto-refresh. ## Install diff --git a/docs/packages/helpers/did-client.md b/docs/packages/helpers/did-client.md index 6ae22138f..b5eb3c9f5 100644 --- a/docs/packages/helpers/did-client.md +++ b/docs/packages/helpers/did-client.md @@ -3,10 +3,10 @@ id: pkg-did-client title: '@bsv/did-client' kind: package domain: helpers -version: '1.3.2' +version: '1.3.3' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/did-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client' @@ -16,6 +16,10 @@ tags: [did, identity, helpers] # @bsv/did-client +The unpublished 1.3.3 candidate refreshes the packed first-party dependency +ranges for the next wallet interoperability release; no independent API or wire +format changes are introduced. Adopt after the dependency graph is published. + > Client for the legacy BSV DID PushDrop overlay, including bounded creation, > revocation, and lookup flows. diff --git a/docs/packages/helpers/fund-wallet.md b/docs/packages/helpers/fund-wallet.md index d209e8165..a71d20c48 100644 --- a/docs/packages/helpers/fund-wallet.md +++ b/docs/packages/helpers/fund-wallet.md @@ -3,10 +3,10 @@ id: pkg-fund-wallet title: '@bsv/fund-wallet' kind: package domain: helpers -version: '1.5.2' +version: '1.5.3' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-31' -last_verified: '2026-08-31' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/fund-wallet' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet' @@ -16,6 +16,10 @@ tags: [helpers, testing, faucet, development] # @bsv/fund-wallet +The unpublished 1.5.3 candidate refreshes the packed first-party dependency +ranges for the next wallet interoperability release; no independent API or wire +format changes are introduced. Adopt after the dependency graph is published. + > Command-line faucet/funding tool for development and testing — funds a remote wallet with satoshis from a local Metanet Desktop wallet via private key derivation. ## Install diff --git a/docs/packages/helpers/simple.md b/docs/packages/helpers/simple.md index 44003045e..c14a3db8c 100644 --- a/docs/packages/helpers/simple.md +++ b/docs/packages/helpers/simple.md @@ -3,10 +3,10 @@ id: pkg-simple title: '@bsv/simple' kind: package domain: helpers -version: '0.6.0' +version: '0.6.1' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-09-08' -last_verified: '2026-09-08' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/simple' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple' @@ -16,6 +16,10 @@ tags: [helpers, simple, payments] # @bsv/simple +The unpublished 0.6.1 candidate refreshes the packed first-party dependency +ranges for the next wallet interoperability release; no independent API or wire +format changes are introduced. Adopt after the dependency graph is published. + > High-level wallet API for browser and server — manage payments, tokens, inscriptions, DIDs, and credentials without wrestling with private keys or transactions. ## Install diff --git a/docs/packages/helpers/wallet-helper.md b/docs/packages/helpers/wallet-helper.md index 929a74bd3..001a7ed9f 100644 --- a/docs/packages/helpers/wallet-helper.md +++ b/docs/packages/helpers/wallet-helper.md @@ -3,10 +3,10 @@ id: pkg-wallet-helper title: '@bsv/wallet-helper' kind: package domain: helpers -version: '0.1.8' +version: '0.1.9' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-helper' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper' @@ -16,6 +16,10 @@ tags: [helpers, wallet, transaction-builder] # @bsv/wallet-helper +The unpublished 0.1.9 candidate refreshes the packed first-party dependency +ranges for the next wallet interoperability release; no independent API or wire +format changes are introduced. Adopt after the dependency graph is published. + > Fluent transaction builder and wallet-compatible script templates for BSV — construct multi-output transactions (P2PKH, ordinals, custom) with method chaining, BRC-29 key derivation, and no private key exposure. `@bsv/wallet-helper` is a good starting point for developers coming from other blockchain ecosystems who expect to build transactions explicitly. It gives you a transaction-builder shape for outputs, scripts, ordinals, metadata, inputs, and explicit change destinations, while still delegating keys and signing to a BRC-100 wallet. diff --git a/docs/packages/messaging/message-box-client.md b/docs/packages/messaging/message-box-client.md index 68e69e6c0..5f3fec371 100644 --- a/docs/packages/messaging/message-box-client.md +++ b/docs/packages/messaging/message-box-client.md @@ -3,10 +3,10 @@ id: pkg-message-box-client title: '@bsv/message-box-client' kind: package domain: messaging -version: '2.5.2' +version: '2.6.0' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-09-18' -last_verified: '2026-09-18' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/message-box-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client' @@ -16,6 +16,8 @@ tags: [messaging, message-box, brc-103, brc-29] # @bsv/message-box-client +The unpublished 2.6 candidate accepts strict bounded base64 transaction bytes at the PeerPay receive boundary, alongside deployed array/byte representations. Outgoing encoding stays unchanged. This does not remove encrypted relay message limits or provide a durable settlement journal. + > Browser- and Node-compatible authenticated store-and-forward messaging, > live WebSockets, peer payments, token settlement, permissions, quotes, and > push-device registration. diff --git a/docs/packages/middleware/402-pay.md b/docs/packages/middleware/402-pay.md index b32311188..b5c927663 100644 --- a/docs/packages/middleware/402-pay.md +++ b/docs/packages/middleware/402-pay.md @@ -3,10 +3,10 @@ id: pkg-402-pay title: '@bsv/402-pay' kind: package domain: middleware -version: '0.3.2' +version: '0.3.3' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-09-08' -last_verified: '2026-09-08' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/402-pay' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay' @@ -16,6 +16,8 @@ tags: [middleware, payment, '402', client] # @bsv/402-pay +The unpublished 0.3.3 candidate documents the BRC-121 adapter boundary. Its runtime wire contract is unchanged; BRC-118 support is provided by the SDK and composed auth/payment middleware, as described in the [payment transport guide](../../guides/brc118-payments.md). + > BRC-121 HTTP 402 Payment Required handler for client and server. Client-side: auto-pays 402 responses. Server-side: middleware/validation for accepting BSV micropayments over HTTP. ## Install diff --git a/docs/packages/middleware/auth-express-middleware.md b/docs/packages/middleware/auth-express-middleware.md index f942e5123..421d1b0e8 100644 --- a/docs/packages/middleware/auth-express-middleware.md +++ b/docs/packages/middleware/auth-express-middleware.md @@ -3,10 +3,10 @@ id: pkg-auth-express-middleware title: '@bsv/auth-express-middleware' kind: package domain: middleware -version: '2.2.5' +version: '2.3.0' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-09-16' -last_verified: '2026-09-16' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/auth-express-middleware' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware' @@ -16,6 +16,8 @@ tags: [middleware, express, auth, brc-103, brc-104] # @bsv/auth-express-middleware +The unpublished 2.3 candidate adds opt-in bounded raw request capture for exact BRC-118 authentication. Mount it before body parsers; multipart extraction happens only after authentication. See the [BRC-118 deployment guide](../../guides/brc118-payments.md). + Express transport for BRC-103 peer-to-peer mutual authentication over BRC-104 HTTP. It handles the public handshake, verifies authenticated application requests, signs responses, and optionally exchanges verifiable diff --git a/docs/packages/middleware/payment-express-middleware.md b/docs/packages/middleware/payment-express-middleware.md index f0378040f..a4441f6ac 100644 --- a/docs/packages/middleware/payment-express-middleware.md +++ b/docs/packages/middleware/payment-express-middleware.md @@ -3,10 +3,10 @@ id: pkg-payment-express-middleware title: '@bsv/payment-express-middleware' kind: package domain: middleware -version: '2.1.7' +version: '2.2.0' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/payment-express-middleware' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware' @@ -16,6 +16,8 @@ tags: [middleware, express, payment, '402', brc-29] # @bsv/payment-express-middleware +The unpublished 2.2 candidate adds opt-in BRC-118 multipart payments behind verified raw authentication. It preserves the existing payment validation, replay and internalization path. See the [BRC-118 deployment guide](../../guides/brc118-payments.md), including CORS and receiver-first rollout. + Express middleware for the legacy authenticated `x-bsv-payment` JSON flow. It runs after `@bsv/auth-express-middleware`, validates an Atomic BEEF payment, atomically rejects transaction-ID reuse, internalizes output zero, and exposes diff --git a/docs/packages/overlays/overlay-discovery-services.md b/docs/packages/overlays/overlay-discovery-services.md index 2edeaa210..0f09ccf09 100644 --- a/docs/packages/overlays/overlay-discovery-services.md +++ b/docs/packages/overlays/overlay-discovery-services.md @@ -4,9 +4,9 @@ title: '@bsv/overlay-discovery-services' kind: package domain: overlays npm: '@bsv/overlay-discovery-services' -version: '2.2.5' -last_updated: '2026-09-18' -last_verified: '2026-09-18' +version: '2.2.6' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services' status: stable @@ -15,6 +15,10 @@ tags: ['overlay', 'discovery'] # @bsv/overlay-discovery-services +The unpublished 2.2.6 candidate refreshes the packed first-party dependency +ranges for the next wallet interoperability release; no independent API or wire +format changes are introduced. Adopt after the dependency graph is published. + > Implements SHIP and SLAP protocols for peer discovery and service advertisement in overlay networks. ## Install diff --git a/docs/packages/overlays/overlay-express.md b/docs/packages/overlays/overlay-express.md index 7b272315c..61904bdc3 100644 --- a/docs/packages/overlays/overlay-express.md +++ b/docs/packages/overlays/overlay-express.md @@ -4,9 +4,9 @@ title: '@bsv/overlay-express' kind: package domain: overlays npm: '@bsv/overlay-express' -version: '2.7.2' -last_updated: '2026-09-18' -last_verified: '2026-09-18' +version: '2.7.3' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express' status: stable @@ -15,6 +15,10 @@ tags: ['overlay', 'express', 'http'] # @bsv/overlay-express +The unpublished 2.7.3 candidate refreshes the packed first-party dependency +ranges for the next wallet interoperability release; no independent API or wire +format changes are introduced. Adopt after the dependency graph is published. + > Opinionated Express.js HTTP server wrapper for @bsv/overlay with built-in configuration, health checks, and peer discovery. ## Install diff --git a/docs/packages/sdk/bsv-sdk.md b/docs/packages/sdk/bsv-sdk.md index e18d02460..fc9155412 100644 --- a/docs/packages/sdk/bsv-sdk.md +++ b/docs/packages/sdk/bsv-sdk.md @@ -3,10 +3,10 @@ id: bsv-sdk title: '@bsv/sdk' kind: package domain: sdk -version: '2.8.0' +version: '2.9.0' npm: '@bsv/sdk' -last_updated: '2026-09-21' -last_verified: '2026-09-21' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 status: stable tags: ['sdk', 'crypto', 'transactions'] @@ -15,7 +15,9 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk' # @bsv/sdk -The current unpublished candidate corrects BUMP offset arithmetic above 32 bits +The unpublished 2.9 candidate adds bounded BRC-118 payment transport to `AuthFetch` and corrects recipient-side BRC-29 derivation. See the [BRC-118 integration and migration guide](../../guides/brc118-payments.md) for preparation, negotiation, exact-byte authentication and uncertain-payment recovery. Existing non-multipart signing preimages and the 8 KiB header selection default are preserved. + +The released 2.8 line corrects BUMP offset arithmetic above 32 bits through `Number.MAX_SAFE_INTEGER`, preserving existing wire encodings. Root calculation, extraction, combination and trimming use the same exact numeric domain; malformed non-integer and unsafe offsets fail explicitly. No consumer diff --git a/docs/packages/wallet/wallet-toolbox-client.md b/docs/packages/wallet/wallet-toolbox-client.md index 120af962a..c280c7482 100644 --- a/docs/packages/wallet/wallet-toolbox-client.md +++ b/docs/packages/wallet/wallet-toolbox-client.md @@ -3,9 +3,9 @@ id: pkg-wallet-toolbox-client title: '@bsv/wallet-toolbox-client' kind: package domain: wallet -version: '2.13.2' -last_updated: '2026-09-10' -last_verified: '2026-09-10' +version: '2.14.0' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client' @@ -15,6 +15,8 @@ tags: [wallet, browser, indexeddb, storage, brc-100] # @bsv/wallet-toolbox-client +The unpublished 2.14 candidate exposes resumable sync with atomic IndexedDB page commits and foreground work between pages. See the [sync contract and rollout guide](../../guides/wallet-sync-reliability.md), including cancellation, inclusive timestamp boundaries and measured native-browser behavior. + `@bsv/wallet-toolbox-client` is the browser-safe Wallet Toolbox distribution. It includes the BRC-100 wallet, signer, services, IndexedDB storage, and remote storage client without Node-only Knex, SQLite, MySQL, or filesystem adapters. diff --git a/docs/packages/wallet/wallet-toolbox-mobile.md b/docs/packages/wallet/wallet-toolbox-mobile.md index 436793d5d..09bb1d0cb 100644 --- a/docs/packages/wallet/wallet-toolbox-mobile.md +++ b/docs/packages/wallet/wallet-toolbox-mobile.md @@ -3,9 +3,9 @@ id: pkg-wallet-toolbox-mobile title: '@bsv/wallet-toolbox-mobile' kind: package domain: wallet -version: '2.13.2' -last_updated: '2026-09-10' -last_verified: '2026-09-10' +version: '2.14.0' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-mobile' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile' @@ -15,6 +15,8 @@ tags: [wallet, react-native, mobile, storage, brc-100] # @bsv/wallet-toolbox-mobile +The unpublished 2.14 candidate exports the portable resumable sync API. Remote destinations retain exclusive execution unless a future capability explicitly guarantees safe concurrency; this package does not acquire a local native database. See the [sync contract and rollout guide](../../guides/wallet-sync-reliability.md). + `@bsv/wallet-toolbox-mobile` is the React Native and mobile-safe Wallet Toolbox distribution. It includes wallet, signer, services, monitoring, and remote storage surfaces without Knex, SQLite/MySQL, IndexedDB, or Node-only IO. diff --git a/docs/packages/wallet/wallet-toolbox.md b/docs/packages/wallet/wallet-toolbox.md index 848281cc2..875ab30f8 100644 --- a/docs/packages/wallet/wallet-toolbox.md +++ b/docs/packages/wallet/wallet-toolbox.md @@ -4,9 +4,9 @@ title: '@bsv/wallet-toolbox' kind: package domain: wallet npm: '@bsv/wallet-toolbox' -version: '2.13.2' -last_updated: '2026-09-21' -last_verified: '2026-09-21' +version: '2.14.0' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 status: stable tags: ['wallet', 'brc100'] @@ -15,6 +15,8 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wall # @bsv/wallet-toolbox +The unpublished 2.14 candidate adds `syncFromReaderResumable`, fair foreground scheduling, network/error preflight and canonical proof recovery. See the [sync contract and rollout guide](../../guides/wallet-sync-reliability.md). Existing sync methods and BRC-38/39/40 formats remain supported. + `@bsv/wallet-toolbox` is the reference toolkit for building BRC-100 wallets. It connects `@bsv/sdk` primitives to wallet storage, key derivation, signing, services, monitoring, permissions, and authentication flows. Use this package when you are building a wallet product, a wallet-like service, or another implementation that must match BRC-100 behavior. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index a905550cb..ecf3d7dbb 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.2` | none | [API and usage](../packages/middleware/402-pay.md) | Custom clients must send a strictly framed BRC-95 Atomic BEEF envelope whose subject is the payment transaction; plain BEEF is no longer accepted. Legacy Atomic BEEF containing unrelated branches remains compatible because the server reduces it to the declared subject and dependency closure. The default replay store is bounded and process-local; production services with more than one serving process or node must inject the same durable atomic PaymentReplayStore everywhere. Low-level validators should retain one wallet object or pass an explicit store. Applications that relied on implicit console diagnostics must supply the optional structured logger. Treat an unchallenged 503 after payment submission as ambiguous and reconcile the transaction before requesting another payment. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.6` | none | [API and usage](../packages/helpers/amountinator.md) | Valid finite inputs retain the public API. Currency identifiers are trimmed and normalized to uppercase; callers that passed non-finite values, empty currencies, coercive options, invalid decimal precision, or non-finite converter results must normalize or reject them before calling because those values now fail closed. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.5` | `2.2.5` | none | [API and usage](../packages/middleware/auth-express-middleware.md) | No valid BRC-103/104 wire bytes are changed. Upgrade to @bsv/auth-express-middleware 2.2.5 with the coordinated @bsv/sdk 2.8.0 release or later. When onCertificatesReceived is configured, the callback must call its approval function before returning; replicated deployments must inject a shared CertificateApprovalStore as well as shared session state. Parsed URL-encoded objects are limited to flat string fields, and unsupported nonempty parsed bodies now fail closed. BRC-104 v0.1 does not sign Host/authority, cookies, forwarding metadata, arbitrary standard request headers, or arbitrary standard response headers; pin authority at a trusted edge and do not authorize from omitted metadata. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.7` | none | [API and usage](../packages/messaging/authsocket-client.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. The connect event/connected property report Socket.IO transport state, not completed BRC-103 authentication; wait for verified application traffic when local behavior requires a known unpinned server. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.2` | `0.1.2` | none | [API and usage](../packages/network/chirp.md) | Valid BRC-167 objects, CHIRP/UHRP locators, host endpoints, and ordinary typed calls retain their wire and API behavior. Runtime configuration/options and number-array bytes must use plain own data properties and exact documented types; malformed, accessor-backed, oversized, expired, or non-canonical values now fail closed. Use fetchClient to customize network transport while retaining AuthFetch; the legacy fetch callback replaces the full authenticated request path and should be limited to tests or a caller-supplied authenticated client. Custom caches and sinks receive owned bytes and cannot mutate returned verified results. Pass signal to build, publish, download, stream, or closure validation when external adapters must be cancellable. CLI resume files are bounded, non-symlink, atomic mode-0600 capabilities, and retrieval refuses to follow or replace an existing output path. Existing @bsv/sdk storage and UHRP routes remain unchanged; BRC-167 remains authoritative. | -| `@bsv/did` | `0.2.6` | `0.2.6` | none | [API and usage](../packages/helpers/did.md) | Valid compact encodings and public API signatures remain compatible. Non-did:key issuers must provide issuerPublicKey from local trust policy; authorization-sensitive verifiers should also set expectedIssuer and expectedVct. Key Binding verification now requires both expectedAudience and a transaction-specific expectedNonce and rejects stale or future proofs. Credential aud requires expectedCredentialAudience. Holder presentation verifies the credential first, requires the holder key to match cnf.jwk, and accepts trusted non-did:key issuer policy through verificationOptions. Nested disclosure selections use full dotted paths. Malformed, ambiguous, accessor-backed, oversized, noncanonical, duplicate, disconnected, or collision-bearing inputs now fail closed. Applications must still evaluate signed status claims under their own credential policy before granting access. | -| `@bsv/did-client` | `1.3.2` | `1.3.2` | none | [API and usage](../packages/helpers/did-client.md) | New revocable DID tokens are locked to the issuer and bind the declared subject in their authenticated payload. Previously issued distinct-subject tokens used subject-owned locks and do not authenticate the issuer/subject relationship needed to reconstruct documented revocation; coordinate reissuance or an application-specific verified migration before relying on issuer revocation. Valid canonical same-party and newly issued flows remain compatible; malformed, oversized, ambiguous, or transaction-mutated results now fail closed. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.2` | none | [API and usage](../packages/helpers/fund-wallet.md) | No public API migration is required for conforming wallets. Custom WalletInterface adapters must return the literal accepted: true verdict after successful internalization; refusals and malformed or coercive results now throw instead of being reported as success. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.2` | `2.5.2` | none | [API and usage](../packages/messaging/message-box-client.md) | No valid Message Box wire format or existing method signature changes. socketOptions, serverIdentityKeysByHost, and maximumPayment are optional additive configuration. Move non-loopback HTTP Message Box deployments to HTTPS; ordinary AuthFetch fallback responses, malformed wallet identities, server identity changes within one client instance, malformed quote/send response shapes, and mismatched payment transactions now fail closed. Applications needing durable identity continuity should configure independently validated serverIdentityKeysByHost pins; keys are normalized per URL origin. Different overlay origins may retain different server identities. Custom WalletInterface implementations used for paid sends must return canonical Atomic BEEF plus its matching transaction ID and must preserve the requested output order when randomizeOutputs is false. Batch payment shape is unchanged, but its single server output must carry the quoted per-recipient delivery fee multiplied by the number of allowed recipients; older underpaying batch implementations are rejected. Client diagnostics, including errors, are now disabled unless enableLogging is true and emit only fixed lifecycle events; applications that need request correlation should add their own non-sensitive identifier rather than restoring raw wallet or message values. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior. Existing return shapes and payment envelopes remain unchanged. Failed or incomplete notification payments stay queued. Resolve uncertain refund-send outcomes before retrying; this patch adds ordering checks, not an exactly-once refund journal. listMessages/listMessagesLite envelope behavior and basket-insertion semantics remain tracked in issue #503. Previously trimmed message-box, message-ID, device-token, and device-ID values must now be supplied in their exact canonical form; valid canonical protocol values are unchanged. | -| `@bsv/overlay` | `2.6.1` | `2.6.1` | none | [API and usage](../packages/overlays/overlay.md) | Stop writes and take coordinated SQL and lookup-store backups before running every new Knex migration. Preflight and reconcile exact duplicate (txid, outputIndex, topic) output keys and (txid, topic) applied-transaction keys from transaction, topic, and lookup evidence; the uniqueness migration intentionally fails rather than deleting security state. Apply topical uniqueness before the additive spentBy column and verify both before serving writes. Prefer roll-forward: older versions do not know these migration names, so an image-only rollback can fail migration-list validation. To restore an older version, keep writes stopped and either use the new migration source to reverse spentBy and topical uniqueness in reverse order after exporting and reconciling every spent/spentBy association, or restore coordinated pre-migration SQL and lookup-store backups. Configure canonical header resolution and public HTTPS endpoints for production network paths; custom storage, topic, discovery, and transport adapters must satisfy the new validation and resource bounds. External topic indexes are not made globally atomic by SQL submission serialization, so retain idempotent compensation for side effects outside the overlay database. Valid canonical overlay wire shapes remain supported. | -| `@bsv/overlay-discovery-services` | `2.2.5` | `2.2.5` | none | [API and usage](../packages/overlays/overlay-discovery-services.md) | Valid canonical advertisements and bounded queries remain compatible. Code that used findAllAdvertisements to enumerate advertisements owned by other identities must use the appropriate public lookup service instead; the wallet helper is now an authenticated owner view. Private-network or plaintext production URIs, malformed tokens, unbounded query shapes, and wallet results that mutate inspected actions now fail closed. | -| `@bsv/overlay-express` | `2.7.2` | `2.7.2` | none | [API and usage](../packages/overlays/overlay-express.md) | Set an ARC callback token of at least 32 bytes before enabling the ARC ingestion route and update the sender to present it. Private-network or plaintext outbound destinations require the explicit allowPrivateHosts development policy; production endpoints must use public HTTPS. Detailed health data is opt-in. Existing canonical public overlay requests and default credential-free CORS remain supported, but operators must roll out callback credentials and egress policy together across every replica. | -| `@bsv/overlay-topics` | `1.8.4` | `1.8.4` | none | [API and usage](../packages/overlays/overlay-topics.md) | Topic and lookup identifiers and valid canonical wire encodings remain unchanged. Audit historical rows for malformed amounts, noncanonical identifiers, incomplete ownership or admin evidence, and ambiguous outpoint linkage before replay or rebuild. Custom state and screening providers must return exact booleans, compare identity keys case-insensitively where documented, conserve exact safe-integer value, and honor bounded query and result contracts. | -| `@bsv/paymail` | `2.4.9` | `2.4.9` | none | [API and usage](../packages/messaging/paymail.md) | Valid public APIs and deployed Paymail wire shapes remain supported; malformed, ambiguous, oversized, local-network, wrong-owner, mutation-backed, or request-mismatched input now fails closed. Production origins and capability endpoints must be credential-free public HTTPS, except exact localhost development; custom transports and resolver configuration must use the documented exact runtime types. Receive-route verifySignature defaults to false for legacy compatibility, so unsigned metadata must not authorize a sender. Even when enabled, the legacy P2P signature authenticates only the transaction ID, not recipient, reference, sender-handle context, endpoint, freshness, or replay state. The historical 6745385c3fc0 advertisement is this package's compatibility behavior and is not the upstream signed/timestamped Basic Address Resolution assurance; a complete correction needs a versioned wire migration. Transaction Negotiation v1 is unauthenticated public input. Handlers must validate outputs, references, thread/freshness policy, proofs, callbacks, and durable replay state before financial or authorization effects. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/payment-express-middleware` | `2.1.7` | `2.1.7` | none | [API and usage](../packages/middleware/payment-express-middleware.md) | No wire or public API migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Wallet adapters must return accepted and optional isMerge as own data properties; inherited/accessor-backed verdicts now fail closed. Overinclusive Atomic BEEF receipts are normalized to the declared subject closure. Production replicas must share one durable atomic replay store, and operators must reconcile a replay-store failure after wallet acceptance before asking a payer to spend again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/sdk` | `2.8.0` | `2.8.0` | none | [API and usage](../packages/sdk/bsv-sdk.md) | Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. The response-encoding correction is included in the existing unpublished 2.8.0 candidate. Conforming servers and non-empty response bytes require no migration; non-conforming servers signing zero for an empty response must use the BRC-104 -1 sentinel. | -| `@bsv/simple` | `0.6.0` | `0.6.0` | none | [API and usage](../packages/helpers/simple.md) | Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. The internal comparator consolidation requires no migration and is included in the existing unpublished 0.6.0 candidate. | -| `@bsv/templates` | `1.10.2` | `1.10.2` | none | [API and usage](../packages/helpers/templates.md) | No API migration is required for valid templates. MandalaToken now rejects locking or decoding amounts outside JavaScript's positive safe-integer range; audit any previously accepted non-exact amount scripts before replay. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.8` | none | [API and usage](../packages/helpers/wallet-helper.md) | getAddress now derives with forSelf: true and returns the caller-owned side of the bilateral relationship. Applications that stored or coordinated the previous peer-owned result must regenerate and exchange the corrected address before sending value. Amount must be an integer from 1 through 1,000 and counterparties must be valid public keys. Valid canonical transaction-builder flows remain supported; malformed, ambiguous, or wallet-mutated results now fail closed. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.13.2` | `2.13.2` | none | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer, wire, or database migration is required for canonical proof validation and retry handling. Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. IndexedDB upgrades automatically to version 6 with a non-unique transaction-ID/user index, preserving existing data and duplicate transaction IDs. Older clients requesting schema version 6 cannot reopen the upgraded database; retain a compatible client for local backups. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing actions, ordinary noSend calls, permission modules, UMP v3 tokens, and active WAB accounts require no client migration. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 6. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share one preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but applications must store each complete snapshot in an OS Keychain, hardware-backed keystore, or comparably trusted secret store because possession of it grants wallet access. Apply every ChainTracks Knex migration before serving traffic. The MySQL repair clears only the rebuildable live-header cache while widening legacy truncating identifier columns to VARCHAR(64), retains authenticated bulk data, and adds the transaction lock row; allow the tracker to repopulate live headers before declaring it healthy. To downgrade, stop all ChainTracks writes, take and verify a database and authenticated-bulk-data backup, then use the current ChaintracksKnexMigrations source to roll down only the 2026-09-17 repair migration ledger entry. Its down step intentionally leaves the repaired VARCHAR(64) and LONGBLOB schema, chaintracks_state lock row, and authenticated bulk files intact. Start older code only after validating that retained schema and data in a non-production copy; never roll down the initial migration, recreate the tables, or restore truncating VARBINARY(32) identifier columns. Existing ChainTracks wire and public API contracts are unchanged. Existing logging integrations remain source compatible, but applications that relied on implicit console output must supply the optional logging callback explicitly. Configure durable download and cache lock timeouts deliberately; neither crash-abandoned lock is reclaimed automatically, so prove no writer remains before removing only the affected lock directory. Custom ChaintracksStorageBulkFileApi implementations must add atomic replaceBulkFiles support before multi-file reconciliation or replacement; older custom adapters now fail closed for those operations. MonitorOptions.maxQueuedDeactivatedHeaders is additive and defaults to 4096; lower it for constrained hosts. Arcade SSE event, pending-count, and pending-byte limits are additive and default to 262144 bytes, 64 events, and 4194304 bytes; lower them for constrained hosts. MonitorOptions.logging and ArcSSEClientOptions.log are additive and replace prior implicit library console output when observability is required. The migration-atomicity fix is included in the existing unpublished 2.13.2 candidate and requires no new schema migration. It prevents future partial migrations; a database already left with unjournaled schema objects by an older version still needs operator-reviewed recovery from a verified backup or an exact schema/journal reconciliation. Never delete migration journal rows or wallet data blindly. These compatible storage fixes are included in the existing unpublished 2.13.2 candidate and require no schema migration. Retry a rejected sync page only after its basket mapping is available. A previously lost basket can be restored by an authoritative newer source update; same-time and older rows never undo a local relinquishment. | -| `@bsv/wallet-toolbox-client` | `2.13.2` | `2.13.2` | none | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. IndexedDB upgrades automatically to version 6 with a non-unique transaction-ID/user index, preserving existing data and duplicate transaction IDs. Older clients requesting schema version 6 cannot reopen the upgraded database; retain a compatible client for local backups. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing browser actions, permission modules, UMP v3 tokens, and active WAB accounts require no client migration; IndexedDB upgrades automatically. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but store each complete snapshot only through browser or extension storage backed by an OS Keychain or comparably trusted secret store. | -| `@bsv/wallet-toolbox-mobile` | `2.13.2` | `2.13.2` | none | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing mobile actions, permission modules, UMP v3 tokens, and active WAB accounts require no client migration. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but store each complete snapshot in the iOS Keychain, Android Keystore-backed encrypted storage, or a comparably trusted secret store. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.5` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.7` | none | [API and usage](../packages/messaging/authsocket-client.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. The connect event/connected property report Socket.IO transport state, not completed BRC-103 authentication; wait for verified application traffic when local behavior requires a known unpinned server. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.2` | `0.1.2` | none | [API and usage](../packages/network/chirp.md) | Valid BRC-167 objects, CHIRP/UHRP locators, host endpoints, and ordinary typed calls retain their wire and API behavior. Runtime configuration/options and number-array bytes must use plain own data properties and exact documented types; malformed, accessor-backed, oversized, expired, or non-canonical values now fail closed. Use fetchClient to customize network transport while retaining AuthFetch; the legacy fetch callback replaces the full authenticated request path and should be limited to tests or a caller-supplied authenticated client. Custom caches and sinks receive owned bytes and cannot mutate returned verified results. Pass signal to build, publish, download, stream, or closure validation when external adapters must be cancellable. CLI resume files are bounded, non-symlink, atomic mode-0600 capabilities, and retrieval refuses to follow or replace an existing output path. Existing @bsv/sdk storage and UHRP routes remain unchanged; BRC-167 remains authoritative. | +| `@bsv/did` | `0.2.6` | `0.2.6` | none | [API and usage](../packages/helpers/did.md) | Valid compact encodings and public API signatures remain compatible. Non-did:key issuers must provide issuerPublicKey from local trust policy; authorization-sensitive verifiers should also set expectedIssuer and expectedVct. Key Binding verification now requires both expectedAudience and a transaction-specific expectedNonce and rejects stale or future proofs. Credential aud requires expectedCredentialAudience. Holder presentation verifies the credential first, requires the holder key to match cnf.jwk, and accepts trusted non-did:key issuer policy through verificationOptions. Nested disclosure selections use full dotted paths. Malformed, ambiguous, accessor-backed, oversized, noncanonical, duplicate, disconnected, or collision-bearing inputs now fail closed. Applications must still evaluate signed status claims under their own credential policy before granting access. | +| `@bsv/did-client` | `1.3.2` | `1.3.3` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.2` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.1` | `2.6.1` | none | [API and usage](../packages/overlays/overlay.md) | Stop writes and take coordinated SQL and lookup-store backups before running every new Knex migration. Preflight and reconcile exact duplicate (txid, outputIndex, topic) output keys and (txid, topic) applied-transaction keys from transaction, topic, and lookup evidence; the uniqueness migration intentionally fails rather than deleting security state. Apply topical uniqueness before the additive spentBy column and verify both before serving writes. Prefer roll-forward: older versions do not know these migration names, so an image-only rollback can fail migration-list validation. To restore an older version, keep writes stopped and either use the new migration source to reverse spentBy and topical uniqueness in reverse order after exporting and reconciling every spent/spentBy association, or restore coordinated pre-migration SQL and lookup-store backups. Configure canonical header resolution and public HTTPS endpoints for production network paths; custom storage, topic, discovery, and transport adapters must satisfy the new validation and resource bounds. External topic indexes are not made globally atomic by SQL submission serialization, so retain idempotent compensation for side effects outside the overlay database. Valid canonical overlay wire shapes remain supported. | +| `@bsv/overlay-discovery-services` | `2.2.5` | `2.2.6` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.2` | `2.7.3` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.8.4` | `1.8.4` | none | [API and usage](../packages/overlays/overlay-topics.md) | Topic and lookup identifiers and valid canonical wire encodings remain unchanged. Audit historical rows for malformed amounts, noncanonical identifiers, incomplete ownership or admin evidence, and ambiguous outpoint linkage before replay or rebuild. Custom state and screening providers must return exact booleans, compare identity keys case-insensitively where documented, conserve exact safe-integer value, and honor bounded query and result contracts. | +| `@bsv/paymail` | `2.4.9` | `2.4.9` | none | [API and usage](../packages/messaging/paymail.md) | Valid public APIs and deployed Paymail wire shapes remain supported; malformed, ambiguous, oversized, local-network, wrong-owner, mutation-backed, or request-mismatched input now fails closed. Production origins and capability endpoints must be credential-free public HTTPS, except exact localhost development; custom transports and resolver configuration must use the documented exact runtime types. Receive-route verifySignature defaults to false for legacy compatibility, so unsigned metadata must not authorize a sender. Even when enabled, the legacy P2P signature authenticates only the transaction ID, not recipient, reference, sender-handle context, endpoint, freshness, or replay state. The historical 6745385c3fc0 advertisement is this package's compatibility behavior and is not the upstream signed/timestamped Basic Address Resolution assurance; a complete correction needs a versioned wire migration. Transaction Negotiation v1 is unauthenticated public input. Handlers must validate outputs, references, thread/freshness policy, proofs, callbacks, and durable replay state before financial or authorization effects. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/payment-express-middleware` | `2.1.7` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.0` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and non-multipart signing retain their wire format. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.2` | none | [API and usage](../packages/helpers/templates.md) | No API migration is required for valid templates. MandalaToken now rejects locking or decoding amounts outside JavaScript's positive safe-integer range; audit any previously accepted non-exact amount scripts before replay. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.13.2` | `2.14.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. | +| `@bsv/wallet-toolbox-client` | `2.13.2` | `2.14.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. | +| `@bsv/wallet-toolbox-mobile` | `2.13.2` | `2.14.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -71,8 +71,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/402-pay.md](../packages/middleware/402-pay.md) - Source: [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) -- Release note: Adds an exact-tarball Vite and esbuild contract for the browser-safe client entry point, including a bundle-size ratchet and an assertion that server exports never leak into browser consumers. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Binds BRC-121 pricing and internalization to the transaction declared by the BRC-95 Atomic BEEF subject, removes unrelated included branches, rejects trailing bytes and non-atomic envelopes, and requires affirmative wallet acceptance before serving paid content. Adds independent bounded atomic transaction replay claims so conforming BRC-100 wallets that omit the non-public isMerge detail cannot authorize duplicate access, preserves actual overpayments in middleware receipts, uses fixed bounded wallet descriptions, makes diagnostics opt-in, and returns an unchallenged 503 after ambiguous wallet or replay-store failures to avoid inducing a second spend. Aligns the development-only Vitest runner and V8 coverage provider at 4.1.11. -- Migration: Custom clients must send a strictly framed BRC-95 Atomic BEEF envelope whose subject is the payment transaction; plain BEEF is no longer accepted. Legacy Atomic BEEF containing unrelated branches remains compatible because the server reduces it to the declared subject and dependency closure. The default replay store is bounded and process-local; production services with more than one serving process or node must inject the same durable atomic PaymentReplayStore everywhere. Low-level validators should retain one wallet object or pass an explicit store. Applications that relied on implicit console diagnostics must supply the optional structured logger. Treat an unchallenged 503 after payment submission as ambiguous and reconcile the transaction before requesting another payment. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. +- Release note: Documents the protocol boundary between this package's BRC-121 header payments and the separate BRC-105/BRC-118 authenticated multipart integration. Runtime and wire behavior are unchanged. +- Migration: No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ------------------------------------------ | ---------------------------------------------- | @@ -95,8 +95,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/helpers/amountinator.md](../packages/helpers/amountinator.md) - Source: [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) -- Release note: Validates finite monetary values, normalized nonempty currency codes, integer decimal precision, grouping flags, converter rates, and conversion results, and formats negative sub-unit amounts from their absolute magnitude without losing the sign. -- Migration: Valid finite inputs retain the public API. Currency identifiers are trimmed and normalized to uppercase; callers that passed non-finite values, empty currencies, coercive options, invalid decimal precision, or non-finite converter results must normalize or reject them before calling because those values now fail closed. +- Release note: Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change. +- Migration: No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------- | -------------------------------------------- | @@ -117,8 +117,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/auth-express-middleware.md](../packages/middleware/auth-express-middleware.md) - Source: [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) -- Release note: Requires the SDK release that binds authenticated BRC-104 request identity to the nonce-selected session; retains application certificate approval against that exact session; canonicalizes parsed request bodies; rejects partial, duplicate, malformed, and unsupported signed input; bounds handshake and response work; contains optional diagnostics; preserves Express sendFile controls; and signs responses emitted through standard Express and Node response methods. -- Migration: No valid BRC-103/104 wire bytes are changed. Upgrade to @bsv/auth-express-middleware 2.2.5 with the coordinated @bsv/sdk 2.8.0 release or later. When onCertificatesReceived is configured, the callback must call its approval function before returning; replicated deployments must inject a shared CertificateApprovalStore as well as shared session state. Parsed URL-encoded objects are limited to flat string fields, and unsupported nonempty parsed bodies now fail closed. BRC-104 v0.1 does not sign Host/authority, cookies, forwarding metadata, arbitrary standard request headers, or arbitrary standard response headers; pin authority at a trusted edge and do not authorize from omitted metadata. +- Release note: Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. +- Migration: Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -202,8 +202,8 @@ CLI entry points: `{"chirp":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/did-client.md](../packages/helpers/did-client.md) - Source: [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) -- Release note: Hardens legacy DID overlay issuance, update, revocation, resolution, and custom-input spending with canonical token, BEEF, outpoint, wallet-metadata, pagination, and final-transaction validation, and makes revocable tokens issuer-owned so the documented issuer revocation path is enforceable. -- Migration: New revocable DID tokens are locked to the issuer and bind the declared subject in their authenticated payload. Previously issued distinct-subject tokens used subject-owned locks and do not authenticate the issuer/subject relationship needed to reconstruct documented revocation; coordinate reissuance or an application-specific verified migration before relying on issuer revocation. Valid canonical same-party and newly issued flows remain compatible; malformed, oversized, ambiguous, or transaction-mutated results now fail closed. +- Release note: Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change. +- Migration: No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | --------------------------------------- | ------------------------------------------- | @@ -226,8 +226,8 @@ CLI entry points: `{"chirp":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/fund-wallet.md](../packages/helpers/fund-wallet.md) - Source: [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) -- Release note: Requires the funding wallet's internalizeAction result to contain accepted: true before reporting a funded transaction as successfully internalized. -- Migration: No public API migration is required for conforming wallets. Custom WalletInterface adapters must return the literal accepted: true verdict after successful internalization; refusals and malformed or coercive results now throw instead of being reported as success. +- Release note: Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change. +- Migration: No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. @@ -265,8 +265,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/messaging/message-box-client.md](../packages/messaging/message-box-client.md) - Source: [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) -- Release note: Adds an optional socketOptions client option that forwards the AuthSocketClient options other than wallet and originator, letting callers select Socket.IO transports such as websocket-only against deployments that do not carry Engine.IO HTTP polling, and reach certificate requests, session management, auth-message concurrency, and error reporting. Also preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Rebuilds the UMD bundle with SDK 2.5.0 support for optional recipient-declared known payment ancestors, allowing compatible wallets to omit those ancestors from payment BEEF. Internalizes notification payments before acknowledgment, passes the configured originator, requires affirmative wallet acceptance, and orders refundable PeerPay processing as internalize, refund send, then acknowledgment. Hardens PeerPay and PeerToken request, response, settlement, and mutation authority. Requires every Message Box HTTP result to remain BRC-103 mutually authenticated, pins one curve-valid server identity per origin with optional durable serverIdentityKeysByHost pins, limits plaintext HTTP to loopback, and independently bounds and verifies overlay advertisement BEEF, output indexes, requested identities, canonical PushDrop envelopes, and signatures. Snapshots outgoing send authority, strictly binds quote rows and send results to requested recipients and message IDs, validates bounded safe-integer fees, adds optional maximumPayment ceilings, requires returned payment Atomic BEEF to preserve every requested script and amount at its exact remittance index, aggregates the quoted server delivery fee across every allowed batch recipient, and makes bounded event-only client diagnostics fully opt-in without logging wallet, message, payment, host, identity, token, transaction, or response data. Live sends now use that same immutable validation and payment ceiling; all room, box, and message identifiers are exact, byte-bounded, and control-free; permission and device responses fail closed on malformed records; push routing identifiers remain data-only rather than visible fallback notification text; and recipient-payment snapshots preserve the intended 32 MiB Atomic BEEF limit without invoking accessors or incorrectly applying the smaller generic JSON-graph limit. -- Migration: No valid Message Box wire format or existing method signature changes. socketOptions, serverIdentityKeysByHost, and maximumPayment are optional additive configuration. Move non-loopback HTTP Message Box deployments to HTTPS; ordinary AuthFetch fallback responses, malformed wallet identities, server identity changes within one client instance, malformed quote/send response shapes, and mismatched payment transactions now fail closed. Applications needing durable identity continuity should configure independently validated serverIdentityKeysByHost pins; keys are normalized per URL origin. Different overlay origins may retain different server identities. Custom WalletInterface implementations used for paid sends must return canonical Atomic BEEF plus its matching transaction ID and must preserve the requested output order when randomizeOutputs is false. Batch payment shape is unchanged, but its single server output must carry the quoted per-recipient delivery fee multiplied by the number of allowed recipients; older underpaying batch implementations are rejected. Client diagnostics, including errors, are now disabled unless enableLogging is true and emit only fixed lifecycle events; applications that need request correlation should add their own non-sensitive identifier rather than restoring raw wallet or message values. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior. Existing return shapes and payment envelopes remain unchanged. Failed or incomplete notification payments stay queued. Resolve uncertain refund-send outcomes before retrying; this patch adds ordering checks, not an exactly-once refund journal. listMessages/listMessagesLite envelope behavior and basket-insertion semantics remain tracked in issue #503. Previously trimmed message-box, message-ID, device-token, and device-ID values must now be supplied in their exact canonical form; valid canonical protocol values are unchanged. +- Release note: Accepts canonical, bounded base64 transaction strings at PeerPay message receive boundaries alongside portable number arrays and legacy numeric-key JSON byte objects. Applies one validation path to list, live and indexed acceptance and rejects malformed or oversized encodings before wallet work or acknowledgement. +- Migration: Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -292,8 +292,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay-discovery-services.md](../packages/overlays/overlay-discovery-services.md) - Source: [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) -- Release note: Authenticates and bounds SHIP and SLAP advertisement discovery with canonical PushDrop, signature, identity, token, URI, BEEF, query, pagination, result, and final-wallet-transaction validation. findAllAdvertisements now returns only advertisements authenticated as owned by the calling wallet identity. -- Migration: Valid canonical advertisements and bounded queries remain compatible. Code that used findAllAdvertisements to enumerate advertisements owned by other identities must use the appropriate public lookup service instead; the wallet helper is now an authenticated owner view. Private-network or plaintext production URIs, malformed tokens, unbounded query shapes, and wallet results that mutate inspected actions now fail closed. +- Release note: Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change. +- Migration: No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------------- | ---------------------------------------------------- | @@ -304,8 +304,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay-express.md](../packages/overlays/overlay-express.md) - Source: [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) -- Release note: Adds direct constructor configuration and hardens public overlay services with DNS-pinned public-HTTPS outbound requests, no redirects, finite deadlines and body limits, canonical reorganization checks, bounded health, search, and monitor routes, sanitized no-store administration pages, and token-gated ARC callbacks. Serializes GASP request failures into one escaped field through the configured logger, including errors containing line separators or throwing serialization hooks. -- Migration: Set an ARC callback token of at least 32 bytes before enabling the ARC ingestion route and update the sender to present it. Private-network or plaintext outbound destinations require the explicit allowPrivateHosts development policy; production endpoints must use public HTTPS. Detailed health data is opt-in. Existing canonical public overlay requests and default credential-free CORS remain supported, but operators must roll out callback credentials and egress policy together across every replica. +- Release note: Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change. +- Migration: No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------------- | ---------------------------------------------------- | @@ -347,8 +347,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/middleware/payment-express-middleware.md](../packages/middleware/payment-express-middleware.md) - Source: [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) -- Release note: Standardizes package quality, strengthens payment middleware validation, edge policy, and failure handling, and shares the host application's Express runtime and types. Validates the wallet remittance before atomically claiming a transaction ID so public BEEF cannot poison the replay store; requires exact own-data wallet verdicts; reduces overinclusive Atomic BEEF to the declared payment and dependency closure before wallet/application use; and contains diagnostic callback failures. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No wire or public API migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Wallet adapters must return accepted and optional isMerge as own data properties; inherited/accessor-backed verdicts now fail closed. Overinclusive Atomic BEEF receipts are normalized to the declared subject closure. Production replicas must share one durable atomic replay store, and operators must reconcile a replay-store failure after wallet acceptance before asking a payer to spend again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. +- Release note: Adds receiver-first BRC-118 negotiation and bounded authenticated multipart payment extraction. Restores the original application payload and media type while retaining the existing BRC-105 amount, derivation, Atomic BEEF, internalization and replay checks. +- Migration: Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -359,8 +359,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/sdk/bsv-sdk.md](../packages/sdk/bsv-sdk.md) - Source: [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) -- Release note: Adds TOTP.generateSecure and TOTP.validateSecure for conventional six-digit zero-padded codes while retaining the published legacy methods, and hardens authenticated identity binding, replay state, transaction framing, BEEF ownership, registry payloads, transport deadlines, script-verifier registration, wallet-result ownership, certificate acquisition, and signing context. Corrects empty authenticated HTTP response bodies to use the BRC-104 -1 length sentinel, restoring verification of conforming bodyless responses. -- Migration: Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. The response-encoding correction is included in the existing unpublished 2.8.0 candidate. Conforming servers and non-empty response bytes require no migration; non-conforming servers signing zero for an empty response must use the BRC-104 -1 sentinel. +- Release note: Adds BRC-118 negotiated, byte-preserving multipart payments to AuthFetch with bounded header/body budgets, prepare-before-broadcast validation, one-transaction submission/retry, cancellation and typed permanent recovery outcomes. Preserves non-multipart signature preimages and fixes BRC-29 recipient child-key derivation. Adds independent Python wire/preimage vectors and composed authenticated HTTP/proxy regression coverage. +- Migration: Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and non-multipart signing retain their wire format. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -444,8 +444,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/simple.md](../packages/helpers/simple.md) - Source: [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) -- Release note: Corrects PushDrop self-derivation ownership, pins Message Box responses to the authenticated peer, hardens wallet, credential, DID, persistence, and transaction boundaries, preserves explicit offline migration identifiers and historical signature verification for pre-0.6 short certificate types while keeping new issuance, remote metadata, and wallet operations canonical 32-byte, and changes the generated server-wallet handler to require explicit authenticated action-level authorization. Shares one tested UTF-16 code-unit comparator across certificate signing and persisted field ordering, preserving historical ordering independently of locale. -- Migration: Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. The internal comparator consolidation requires no migration and is included in the existing unpublished 0.6.0 candidate. +- Release note: Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change. +- Migration: No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | -------------------------------------------- | ------------------------------------------------ | @@ -496,8 +496,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/wallet-helper.md](../packages/helpers/wallet-helper.md) - Source: [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) -- Release note: Corrects caller-owned bilateral address derivation and hardens address generation, transaction building, prevout binding, P2PKH, Ordinal, OrdLock, preimage, sighash, OP_RETURN, script, amount, output, and final-wallet-transaction validation. -- Migration: getAddress now derives with forSelf: true and returns the caller-owned side of the bilateral relationship. Applications that stored or coordinated the previous peer-owned result must regenerate and exchange the corrected address before sending value. Amount must be an integer from 1 through 1,000 and counterparties must be valid public keys. Valid canonical transaction-builder flows remain supported; malformed, ambiguous, or wallet-mutated results now fail closed. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. +- Release note: Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change. +- Migration: No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------- | -------------------------------------------- | @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Validates every acquired Merkle proof against the active ChainTracks root, fails over when a provider returns an orphan proof, and retries unresolved reorganization heights without stopping the forward review cursor. Accepts valid compound proofs with multiple marked transactions, rotates failed retries behind waiting heights across restarts, and preserves temporarily ineligible retries when the chain tip retreats. Reconciles stale RPC sync proofs only after full validation of current proof metadata, preserving raw transactions and wallet references. Adds optional getValidatedMerklePath provider failover with independent per-call provider traversal. Bounds concurrent authenticated proof validation to eight checks and drains failures before rejecting a page. Adds conservative initial sync pages and per-copy adaptation to committed page latency, with proof-work limits independent of byte budgets. Adds negotiated bounded sync transfers, full-frame integrity verification, resumable staged uploads and checkpoint replay protection for oversized records. Adds IndexedDB schema version 6 and bounded sync identity/relation and indexed proof-batch lookups to avoid full-wallet scans during large local restores. Uses negotiated binary JSON for large schema-defined sync response byte arrays while retaining legacy wire arrays. Adds negotiated compact committed sync checkpoints and faster binary JSON parsing without scalar-byte reviver callbacks. Adds adaptive wallet-storage sync reads, optional progress totals, disambiguated checkpoints, bounded HTTP 413 recovery, and MySQL/SQLite source indexes while retaining legacy peer compatibility. Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Makes new WAB-to-UMP registrations interruption-safe through an explicit pending lifecycle and idempotent finalization while active and legacy account mismatches remain fail-closed. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Prevents reuse or coalescing of spending approvals, verifies certificate signatures fail closed during direct acquisition, issuer acquisition, and overlay discovery, paginates complete spending history, requires HTTPS for credential-bearing transports, prevents credential logging, documents snapshots as wallet-equivalent secrets, validates every remote ChainTracks and WhatsOnChain header under bounded HTTP, stream, manifest, and WebSocket controls, requires public HTTPS for service-discovered CDN links, authenticates proof of work before chain-work selection, and caps/copy-isolates submitted and live-header queues. Isolates and authenticates local ChainTracks storage, serializes tip mutation, repairs MySQL header/blob types, strictly validates bulk manifests and stored metadata, and bounds legacy readers, filesystem I/O, exporters, and lock queues. Prevents rejected, stale, or caller-written Block Headers Service Merkle roots from becoming authoritative; validates and bounds its canonical header responses; validates ChainTracks construction limits; and makes library logging opt-in. Makes ChainTracks startup failures awaited and retryable, destruction complete, event subscriptions bounded and isolated, provider results and diagnostics safe, asynchronous validation inputs owned, durable download and cache mutations cross-process serialized, low-level header/work primitives canonical, and destructive migration failures visible. Commits built-in bulk-file replacements atomically, keeps memory behind durable writes, rejects remote local identities and inconsistent event topology, and bounds remote-client subscriptions, timers, metadata, and diagnostics. Independently validates and bounds wallet-monitor reorganization work, coalesces prepared-proof invalidation, cleans up partial subscriptions, and contains host callback failures. Serializes and bounds Arcade SSE admission and cursor commits, prevents successor dispatch after failure, and repairs monitor/daemon startup, task setup, diagnostic, and teardown lifecycle. Binds every user-scoped synchronization subquery and storage-identity migration value rather than interpolating runtime values into SQL. Restores transactional SQLite schema migrations so interrupted DDL and its migration journal entry roll back together, while restoring foreign-key enforcement on success or failure. Keeps cold raw-transaction reads on the caller-owned database transaction without caching uncommitted settings or starting background work; permits absent optional inputBEEF. Rejects missing output-basket mappings during sync and applies valid newer basket changes. -- Migration: No consumer, wire, or database migration is required for canonical proof validation and retry handling. Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. IndexedDB upgrades automatically to version 6 with a non-unique transaction-ID/user index, preserving existing data and duplicate transaction IDs. Older clients requesting schema version 6 cannot reopen the upgraded database; retain a compatible client for local backups. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing actions, ordinary noSend calls, permission modules, UMP v3 tokens, and active WAB accounts require no client migration. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 6. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share one preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but applications must store each complete snapshot in an OS Keychain, hardware-backed keystore, or comparably trusted secret store because possession of it grants wallet access. Apply every ChainTracks Knex migration before serving traffic. The MySQL repair clears only the rebuildable live-header cache while widening legacy truncating identifier columns to VARCHAR(64), retains authenticated bulk data, and adds the transaction lock row; allow the tracker to repopulate live headers before declaring it healthy. To downgrade, stop all ChainTracks writes, take and verify a database and authenticated-bulk-data backup, then use the current ChaintracksKnexMigrations source to roll down only the 2026-09-17 repair migration ledger entry. Its down step intentionally leaves the repaired VARCHAR(64) and LONGBLOB schema, chaintracks_state lock row, and authenticated bulk files intact. Start older code only after validating that retained schema and data in a non-production copy; never roll down the initial migration, recreate the tables, or restore truncating VARBINARY(32) identifier columns. Existing ChainTracks wire and public API contracts are unchanged. Existing logging integrations remain source compatible, but applications that relied on implicit console output must supply the optional logging callback explicitly. Configure durable download and cache lock timeouts deliberately; neither crash-abandoned lock is reclaimed automatically, so prove no writer remains before removing only the affected lock directory. Custom ChaintracksStorageBulkFileApi implementations must add atomic replaceBulkFiles support before multi-file reconciliation or replacement; older custom adapters now fail closed for those operations. MonitorOptions.maxQueuedDeactivatedHeaders is additive and defaults to 4096; lower it for constrained hosts. Arcade SSE event, pending-count, and pending-byte limits are additive and default to 262144 bytes, 64 events, and 4194304 bytes; lower them for constrained hosts. MonitorOptions.logging and ArcSSEClientOptions.log are additive and replace prior implicit library console output when observability is required. The migration-atomicity fix is included in the existing unpublished 2.13.2 candidate and requires no new schema migration. It prevents future partial migrations; a database already left with unjournaled schema objects by an older version still needs operator-reviewed recovery from a verified backup or an exact schema/journal reconciliation. Never delete migration journal rows or wallet data blindly. These compatible storage fixes are included in the existing unpublished 2.13.2 candidate and require no schema migration. Retry a rejected sync page only after its basket mapping is available. A previously lost basket can be restored by an authoritative newer source update; same-time and older rows never undo a local relinquishment. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -537,8 +537,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Adds optional getValidatedMerklePath provider failover with independent per-call provider traversal. Adds conservative initial sync pages and per-copy adaptation to committed page latency, with proof-work limits independent of byte budgets. Adds negotiated bounded sync transfers, full-frame integrity verification, resumable staged uploads and checkpoint replay protection for oversized records. Adds IndexedDB schema version 6 and bounded sync identity/relation and indexed proof-batch lookups to avoid full-wallet scans during large local restores. Adds negotiated compact committed sync checkpoints and faster binary JSON parsing without scalar-byte reviver callbacks. Adds adaptive wallet-storage sync reads, optional progress totals, disambiguated checkpoints, bounded HTTP 413 recovery, and MySQL/SQLite source indexes while retaining legacy peer compatibility. Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 6 lifecycle state, remote storage capability negotiation, default monitor coordination, interruption-safe WAB registration recovery, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. Carries the spending-authorization, certificate-signature, complete-accounting, credential-transport, and credential-log security corrections into the browser package while documenting snapshots as wallet-equivalent secrets. Aligns the development-only Vitest runner and V8 coverage provider at 4.1.11; consumer runtime behavior is unchanged. -- Migration: Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. IndexedDB upgrades automatically to version 6 with a non-unique transaction-ID/user index, preserving existing data and duplicate transaction IDs. Older clients requesting schema version 6 cannot reopen the upgraded database; retain a compatible client for local backups. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing browser actions, permission modules, UMP v3 tokens, and active WAB accounts require no client migration; IndexedDB upgrades automatically. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but store each complete snapshot only through browser or extension storage backed by an OS Keychain or comparably trusted secret store. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -549,8 +549,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Adds optional getValidatedMerklePath provider failover with independent per-call provider traversal. Adds conservative initial sync pages and per-copy adaptation to committed page latency, with proof-work limits independent of byte budgets. Adds negotiated bounded sync transfers, full-frame integrity verification, resumable staged uploads and checkpoint replay protection for oversized records. Adds negotiated compact committed sync checkpoints and faster binary JSON parsing without scalar-byte reviver callbacks. Adds adaptive wallet-storage sync reads, optional progress totals, disambiguated checkpoints, bounded HTTP 413 recovery, and MySQL/SQLite source indexes while retaining legacy peer compatibility. Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, interruption-safe WAB registration recovery, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. Carries the spending-authorization, certificate-signature, complete-accounting, credential-transport, and credential-log security corrections into the React Native package while documenting snapshots as wallet-equivalent secrets. Aligns the development-only Vitest runner and V8 coverage provider at 4.1.11; consumer runtime behavior is unchanged. -- Migration: Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing mobile actions, permission modules, UMP v3 tokens, and active WAB accounts require no client migration. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but store each complete snapshot in the iOS Keychain, Android Keystore-backed encrypted storage, or a comparably trusted secret store. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 11c9cd916..7ac5f0b12 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -39,38 +39,38 @@ authorized release action. | --- | --- | --- | --- | --- | --- | --- | --- | | content | `@bsv/lch` | `0.2.0` | browser-library | browser-bundler, browser-esm, cli, node-esm | browser, node | `>=22` | [packages/content/lch](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch) | | helpers | `@bsv/air-gap` | `0.1.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/air-gap](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) | -| helpers | `@bsv/amountinator` | `2.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | +| helpers | `@bsv/amountinator` | `2.1.7` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | | helpers | `@bsv/did` | `0.2.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | -| helpers | `@bsv/did-client` | `1.3.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | -| helpers | `@bsv/fund-wallet` | `1.5.2` | cli | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | -| helpers | `@bsv/simple` | `0.6.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | +| helpers | `@bsv/did-client` | `1.3.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | +| helpers | `@bsv/fund-wallet` | `1.5.3` | cli | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | +| helpers | `@bsv/simple` | `0.6.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | | helpers | `@bsv/templates` | `1.10.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | -| helpers | `@bsv/wallet-helper` | `0.1.8` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | +| helpers | `@bsv/wallet-helper` | `0.1.9` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | | helpers | `create-bsv-app` | `1.1.2` | cli | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | | messaging | `@bsv/authsocket` | `2.1.8` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | | messaging | `@bsv/authsocket-client` | `2.1.7` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | -| messaging | `@bsv/message-box-client` | `2.5.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | +| messaging | `@bsv/message-box-client` | `2.6.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | | messaging | `@bsv/paymail` | `2.4.9` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) | -| middleware | `@bsv/402-pay` | `0.3.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | +| middleware | `@bsv/402-pay` | `0.3.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | | middleware | `@bsv/auth` | `0.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) | -| middleware | `@bsv/auth-express-middleware` | `2.2.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | -| middleware | `@bsv/payment-express-middleware` | `2.1.7` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) | +| middleware | `@bsv/auth-express-middleware` | `2.3.0` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | +| middleware | `@bsv/payment-express-middleware` | `2.2.0` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) | | network | `@bsv/chirp` | `0.1.2` | browser-library | browser-bundler, browser-esm, cli, node-esm | browser, node | `>=22` | [packages/network/chirp](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/chirp) | | network | `@bsv/teranode-listener` | `1.1.6` | node-library | node-esm | node | `>=22` | [packages/network/ts-p2p](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p) | | overlays | `@bsv/gasp` | `1.3.7` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/overlays/gasp-core](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core) | | overlays | `@bsv/overlay` | `2.6.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay) | -| overlays | `@bsv/overlay-discovery-services` | `2.2.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | -| overlays | `@bsv/overlay-express` | `2.7.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | +| overlays | `@bsv/overlay-discovery-services` | `2.2.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | +| overlays | `@bsv/overlay-express` | `2.7.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | | overlays | `@bsv/overlay-topics` | `1.8.4` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | -| sdk | `@bsv/sdk` | `2.8.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | +| sdk | `@bsv/sdk` | `2.9.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | | sdk | `@bsv/verifast` | `0.3.6` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | | wallet | `@bsv/btms` | `1.2.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | | wallet | `@bsv/btms-permission-module` | `1.2.1` | node-library | node-esm | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | | wallet | `@bsv/ecpm-permission-module` | `0.1.1` | browser-library | browser-bundler, browser-esm, node-esm | browser, node | `>=22` | [packages/wallet/ecpm-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module) | | wallet | `@bsv/wallet-relay` | `0.5.1` | cli-library | browser-bundler, browser-esm, cli, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) | -| wallet | `@bsv/wallet-toolbox` | `2.13.2` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | -| wallet | `@bsv/wallet-toolbox-client` | `2.13.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | -| wallet | `@bsv/wallet-toolbox-mobile` | `2.13.2` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | +| wallet | `@bsv/wallet-toolbox` | `2.14.0` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | +| wallet | `@bsv/wallet-toolbox-client` | `2.14.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | +| wallet | `@bsv/wallet-toolbox-mobile` | `2.14.0` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | ## Standalone infrastructure manifests @@ -106,11 +106,11 @@ recorded container release route; they are not published by the public-package j | Metric | Current value | | --- | --- | -| Vector files | 77 | -| Vectors | 6699 | -| Structurally passed | 6488 | +| Vector files | 78 | +| Vectors | 6705 | +| Structurally passed | 6494 | | Governed skips | 211 | -| Required parity vectors | 6495 | +| Required parity vectors | 6501 | | Intended parity vectors | 204 | | Explicitly skipped vector entries | 7 | | Corpus metadata revision | 2026-09-23 | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 5077ae8b7..d7cb1ee38 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -6,9 +6,9 @@ { "name": "@bsv/402-pay", "publishedVersion": "0.3.2", - "releaseType": "none", - "summary": "Adds an exact-tarball Vite and esbuild contract for the browser-safe client entry point, including a bundle-size ratchet and an assertion that server exports never leak into browser consumers. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Binds BRC-121 pricing and internalization to the transaction declared by the BRC-95 Atomic BEEF subject, removes unrelated included branches, rejects trailing bytes and non-atomic envelopes, and requires affirmative wallet acceptance before serving paid content. Adds independent bounded atomic transaction replay claims so conforming BRC-100 wallets that omit the non-public isMerge detail cannot authorize duplicate access, preserves actual overpayments in middleware receipts, uses fixed bounded wallet descriptions, makes diagnostics opt-in, and returns an unchallenged 503 after ambiguous wallet or replay-store failures to avoid inducing a second spend. Aligns the development-only Vitest runner and V8 coverage provider at 4.1.11.", - "migration": "Custom clients must send a strictly framed BRC-95 Atomic BEEF envelope whose subject is the payment transaction; plain BEEF is no longer accepted. Legacy Atomic BEEF containing unrelated branches remains compatible because the server reduces it to the declared subject and dependency closure. The default replay store is bounded and process-local; production services with more than one serving process or node must inject the same durable atomic PaymentReplayStore everywhere. Low-level validators should retain one wallet object or pass an explicit store. Applications that relied on implicit console diagnostics must supply the optional structured logger. Treat an unchallenged 503 after payment submission as ambiguous and reconcile the transaction before requesting another payment. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package." + "releaseType": "patch", + "summary": "Documents the protocol boundary between this package's BRC-121 header payments and the separate BRC-105/BRC-118 authenticated multipart integration. Runtime and wire behavior are unchanged.", + "migration": "No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol." }, { "name": "@bsv/air-gap", @@ -20,9 +20,9 @@ { "name": "@bsv/amountinator", "publishedVersion": "2.1.6", - "releaseType": "none", - "summary": "Validates finite monetary values, normalized nonempty currency codes, integer decimal precision, grouping flags, converter rates, and conversion results, and formats negative sub-unit amounts from their absolute magnitude without losing the sign.", - "migration": "Valid finite inputs retain the public API. Currency identifiers are trimmed and normalized to uppercase; callers that passed non-finite values, empty currencies, coercive options, invalid decimal precision, or non-finite converter results must normalize or reject them before calling because those values now fail closed." + "releaseType": "patch", + "summary": "Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change.", + "migration": "No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins." }, { "name": "@bsv/auth", @@ -34,9 +34,9 @@ { "name": "@bsv/auth-express-middleware", "publishedVersion": "2.2.5", - "releaseType": "none", - "summary": "Requires the SDK release that binds authenticated BRC-104 request identity to the nonce-selected session; retains application certificate approval against that exact session; canonicalizes parsed request bodies; rejects partial, duplicate, malformed, and unsupported signed input; bounds handshake and response work; contains optional diagnostics; preserves Express sendFile controls; and signs responses emitted through standard Express and Node response methods.", - "migration": "No valid BRC-103/104 wire bytes are changed. Upgrade to @bsv/auth-express-middleware 2.2.5 with the coordinated @bsv/sdk 2.8.0 release or later. When onCertificatesReceived is configured, the callback must call its approval function before returning; replicated deployments must inject a shared CertificateApprovalStore as well as shared session state. Parsed URL-encoded objects are limited to flat string fields, and unsupported nonempty parsed bodies now fail closed. BRC-104 v0.1 does not sign Host/authority, cookies, forwarding metadata, arbitrary standard request headers, or arbitrary standard response headers; pin authority at a trusted edge and do not authorize from omitted metadata." + "releaseType": "minor", + "summary": "Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior.", + "migration": "Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate." }, { "name": "@bsv/authsocket", @@ -97,16 +97,16 @@ { "name": "@bsv/did-client", "publishedVersion": "1.3.2", - "releaseType": "none", - "summary": "Hardens legacy DID overlay issuance, update, revocation, resolution, and custom-input spending with canonical token, BEEF, outpoint, wallet-metadata, pagination, and final-transaction validation, and makes revocable tokens issuer-owned so the documented issuer revocation path is enforceable.", - "migration": "New revocable DID tokens are locked to the issuer and bind the declared subject in their authenticated payload. Previously issued distinct-subject tokens used subject-owned locks and do not authenticate the issuer/subject relationship needed to reconstruct documented revocation; coordinate reissuance or an application-specific verified migration before relying on issuer revocation. Valid canonical same-party and newly issued flows remain compatible; malformed, oversized, ambiguous, or transaction-mutated results now fail closed." + "releaseType": "patch", + "summary": "Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change.", + "migration": "No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins." }, { "name": "@bsv/fund-wallet", "publishedVersion": "1.5.2", - "releaseType": "none", - "summary": "Requires the funding wallet's internalizeAction result to contain accepted: true before reporting a funded transaction as successfully internalized.", - "migration": "No public API migration is required for conforming wallets. Custom WalletInterface adapters must return the literal accepted: true verdict after successful internalization; refusals and malformed or coercive results now throw instead of being reported as success." + "releaseType": "patch", + "summary": "Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change.", + "migration": "No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins." }, { "name": "@bsv/gasp", @@ -118,9 +118,9 @@ { "name": "@bsv/message-box-client", "publishedVersion": "2.5.2", - "releaseType": "none", - "summary": "Adds an optional socketOptions client option that forwards the AuthSocketClient options other than wallet and originator, letting callers select Socket.IO transports such as websocket-only against deployments that do not carry Engine.IO HTTP polling, and reach certificate requests, session management, auth-message concurrency, and error reporting. Also preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Rebuilds the UMD bundle with SDK 2.5.0 support for optional recipient-declared known payment ancestors, allowing compatible wallets to omit those ancestors from payment BEEF. Internalizes notification payments before acknowledgment, passes the configured originator, requires affirmative wallet acceptance, and orders refundable PeerPay processing as internalize, refund send, then acknowledgment. Hardens PeerPay and PeerToken request, response, settlement, and mutation authority. Requires every Message Box HTTP result to remain BRC-103 mutually authenticated, pins one curve-valid server identity per origin with optional durable serverIdentityKeysByHost pins, limits plaintext HTTP to loopback, and independently bounds and verifies overlay advertisement BEEF, output indexes, requested identities, canonical PushDrop envelopes, and signatures. Snapshots outgoing send authority, strictly binds quote rows and send results to requested recipients and message IDs, validates bounded safe-integer fees, adds optional maximumPayment ceilings, requires returned payment Atomic BEEF to preserve every requested script and amount at its exact remittance index, aggregates the quoted server delivery fee across every allowed batch recipient, and makes bounded event-only client diagnostics fully opt-in without logging wallet, message, payment, host, identity, token, transaction, or response data. Live sends now use that same immutable validation and payment ceiling; all room, box, and message identifiers are exact, byte-bounded, and control-free; permission and device responses fail closed on malformed records; push routing identifiers remain data-only rather than visible fallback notification text; and recipient-payment snapshots preserve the intended 32 MiB Atomic BEEF limit without invoking accessors or incorrectly applying the smaller generic JSON-graph limit.", - "migration": "No valid Message Box wire format or existing method signature changes. socketOptions, serverIdentityKeysByHost, and maximumPayment are optional additive configuration. Move non-loopback HTTP Message Box deployments to HTTPS; ordinary AuthFetch fallback responses, malformed wallet identities, server identity changes within one client instance, malformed quote/send response shapes, and mismatched payment transactions now fail closed. Applications needing durable identity continuity should configure independently validated serverIdentityKeysByHost pins; keys are normalized per URL origin. Different overlay origins may retain different server identities. Custom WalletInterface implementations used for paid sends must return canonical Atomic BEEF plus its matching transaction ID and must preserve the requested output order when randomizeOutputs is false. Batch payment shape is unchanged, but its single server output must carry the quoted per-recipient delivery fee multiplied by the number of allowed recipients; older underpaying batch implementations are rejected. Client diagnostics, including errors, are now disabled unless enableLogging is true and emit only fixed lifecycle events; applications that need request correlation should add their own non-sensitive identifier rather than restoring raw wallet or message values. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior. Existing return shapes and payment envelopes remain unchanged. Failed or incomplete notification payments stay queued. Resolve uncertain refund-send outcomes before retrying; this patch adds ordering checks, not an exactly-once refund journal. listMessages/listMessagesLite envelope behavior and basket-insertion semantics remain tracked in issue #503. Previously trimmed message-box, message-ID, device-token, and device-ID values must now be supplied in their exact canonical form; valid canonical protocol values are unchanged." + "releaseType": "minor", + "summary": "Accepts canonical, bounded base64 transaction strings at PeerPay message receive boundaries alongside portable number arrays and legacy numeric-key JSON byte objects. Applies one validation path to list, live and indexed acceptance and rejects malformed or oversized encodings before wallet work or acknowledgement.", + "migration": "Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction." }, { "name": "@bsv/overlay", @@ -132,16 +132,16 @@ { "name": "@bsv/overlay-discovery-services", "publishedVersion": "2.2.5", - "releaseType": "none", - "summary": "Authenticates and bounds SHIP and SLAP advertisement discovery with canonical PushDrop, signature, identity, token, URI, BEEF, query, pagination, result, and final-wallet-transaction validation. findAllAdvertisements now returns only advertisements authenticated as owned by the calling wallet identity.", - "migration": "Valid canonical advertisements and bounded queries remain compatible. Code that used findAllAdvertisements to enumerate advertisements owned by other identities must use the appropriate public lookup service instead; the wallet helper is now an authenticated owner view. Private-network or plaintext production URIs, malformed tokens, unbounded query shapes, and wallet results that mutate inspected actions now fail closed." + "releaseType": "patch", + "summary": "Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change.", + "migration": "No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins." }, { "name": "@bsv/overlay-express", "publishedVersion": "2.7.2", - "releaseType": "none", - "summary": "Adds direct constructor configuration and hardens public overlay services with DNS-pinned public-HTTPS outbound requests, no redirects, finite deadlines and body limits, canonical reorganization checks, bounded health, search, and monitor routes, sanitized no-store administration pages, and token-gated ARC callbacks. Serializes GASP request failures into one escaped field through the configured logger, including errors containing line separators or throwing serialization hooks.", - "migration": "Set an ARC callback token of at least 32 bytes before enabling the ARC ingestion route and update the sender to present it. Private-network or plaintext outbound destinations require the explicit allowPrivateHosts development policy; production endpoints must use public HTTPS. Detailed health data is opt-in. Existing canonical public overlay requests and default credential-free CORS remain supported, but operators must roll out callback credentials and egress policy together across every replica." + "releaseType": "patch", + "summary": "Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change.", + "migration": "No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins." }, { "name": "@bsv/overlay-topics", @@ -160,23 +160,23 @@ { "name": "@bsv/payment-express-middleware", "publishedVersion": "2.1.7", - "releaseType": "none", - "summary": "Standardizes package quality, strengthens payment middleware validation, edge policy, and failure handling, and shares the host application's Express runtime and types. Validates the wallet remittance before atomically claiming a transaction ID so public BEEF cannot poison the replay store; requires exact own-data wallet verdicts; reduces overinclusive Atomic BEEF to the declared payment and dependency closure before wallet/application use; and contains diagnostic callback failures. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No wire or public API migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Wallet adapters must return accepted and optional isMerge as own data properties; inherited/accessor-backed verdicts now fail closed. Overinclusive Atomic BEEF receipts are normalized to the declared subject closure. Production replicas must share one durable atomic replay store, and operators must reconcile a replay-store failure after wallet acceptance before asking a payer to spend again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package." + "releaseType": "minor", + "summary": "Adds receiver-first BRC-118 negotiation and bounded authenticated multipart payment extraction. Restores the original application payload and media type while retaining the existing BRC-105 amount, derivation, Atomic BEEF, internalization and replay checks.", + "migration": "Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide." }, { "name": "@bsv/sdk", "publishedVersion": "2.8.0", - "releaseType": "none", - "summary": "Adds TOTP.generateSecure and TOTP.validateSecure for conventional six-digit zero-padded codes while retaining the published legacy methods, and hardens authenticated identity binding, replay state, transaction framing, BEEF ownership, registry payloads, transport deadlines, script-verifier registration, wallet-result ownership, certificate acquisition, and signing context. Corrects empty authenticated HTTP response bodies to use the BRC-104 -1 length sentinel, restoring verification of conforming bodyless responses.", - "migration": "Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. The response-encoding correction is included in the existing unpublished 2.8.0 candidate. Conforming servers and non-empty response bytes require no migration; non-conforming servers signing zero for an empty response must use the BRC-104 -1 sentinel." + "releaseType": "minor", + "summary": "Adds BRC-118 negotiated, byte-preserving multipart payments to AuthFetch with bounded header/body budgets, prepare-before-broadcast validation, one-transaction submission/retry, cancellation and typed permanent recovery outcomes. Preserves non-multipart signature preimages and fixes BRC-29 recipient child-key derivation. Adds independent Python wire/preimage vectors and composed authenticated HTTP/proxy regression coverage.", + "migration": "Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and non-multipart signing retain their wire format. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins." }, { "name": "@bsv/simple", "publishedVersion": "0.6.0", - "releaseType": "none", - "summary": "Corrects PushDrop self-derivation ownership, pins Message Box responses to the authenticated peer, hardens wallet, credential, DID, persistence, and transaction boundaries, preserves explicit offline migration identifiers and historical signature verification for pre-0.6 short certificate types while keeping new issuance, remote metadata, and wallet operations canonical 32-byte, and changes the generated server-wallet handler to require explicit authenticated action-level authorization. Shares one tested UTF-16 code-unit comparator across certificate signing and persisted field ordering, preserving historical ordering independently of locale.", - "migration": "Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. The internal comparator consolidation requires no migration and is included in the existing unpublished 0.6.0 candidate." + "releaseType": "patch", + "summary": "Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change.", + "migration": "No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins." }, { "name": "@bsv/templates", @@ -202,9 +202,9 @@ { "name": "@bsv/wallet-helper", "publishedVersion": "0.1.8", - "releaseType": "none", - "summary": "Corrects caller-owned bilateral address derivation and hardens address generation, transaction building, prevout binding, P2PKH, Ordinal, OrdLock, preimage, sighash, OP_RETURN, script, amount, output, and final-wallet-transaction validation.", - "migration": "getAddress now derives with forSelf: true and returns the caller-owned side of the bilateral relationship. Applications that stored or coordinated the previous peer-owned result must regenerate and exchange the corrected address before sending value. Amount must be an integer from 1 through 1,000 and counterparties must be valid public keys. Valid canonical transaction-builder flows remain supported; malformed, ambiguous, or wallet-mutated results now fail closed. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package." + "releaseType": "patch", + "summary": "Refreshes the packed first-party dependency ranges for the next wallet sync, proof recovery and BRC-118 release graph. This package adds no independent API or wire-format change.", + "migration": "No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins." }, { "name": "@bsv/wallet-relay", @@ -216,23 +216,23 @@ { "name": "@bsv/wallet-toolbox", "publishedVersion": "2.13.2", - "releaseType": "none", - "summary": "Validates every acquired Merkle proof against the active ChainTracks root, fails over when a provider returns an orphan proof, and retries unresolved reorganization heights without stopping the forward review cursor. Accepts valid compound proofs with multiple marked transactions, rotates failed retries behind waiting heights across restarts, and preserves temporarily ineligible retries when the chain tip retreats. Reconciles stale RPC sync proofs only after full validation of current proof metadata, preserving raw transactions and wallet references. Adds optional getValidatedMerklePath provider failover with independent per-call provider traversal. Bounds concurrent authenticated proof validation to eight checks and drains failures before rejecting a page. Adds conservative initial sync pages and per-copy adaptation to committed page latency, with proof-work limits independent of byte budgets. Adds negotiated bounded sync transfers, full-frame integrity verification, resumable staged uploads and checkpoint replay protection for oversized records. Adds IndexedDB schema version 6 and bounded sync identity/relation and indexed proof-batch lookups to avoid full-wallet scans during large local restores. Uses negotiated binary JSON for large schema-defined sync response byte arrays while retaining legacy wire arrays. Adds negotiated compact committed sync checkpoints and faster binary JSON parsing without scalar-byte reviver callbacks. Adds adaptive wallet-storage sync reads, optional progress totals, disambiguated checkpoints, bounded HTTP 413 recovery, and MySQL/SQLite source indexes while retaining legacy peer compatibility. Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Makes new WAB-to-UMP registrations interruption-safe through an explicit pending lifecycle and idempotent finalization while active and legacy account mismatches remain fail-closed. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Prevents reuse or coalescing of spending approvals, verifies certificate signatures fail closed during direct acquisition, issuer acquisition, and overlay discovery, paginates complete spending history, requires HTTPS for credential-bearing transports, prevents credential logging, documents snapshots as wallet-equivalent secrets, validates every remote ChainTracks and WhatsOnChain header under bounded HTTP, stream, manifest, and WebSocket controls, requires public HTTPS for service-discovered CDN links, authenticates proof of work before chain-work selection, and caps/copy-isolates submitted and live-header queues. Isolates and authenticates local ChainTracks storage, serializes tip mutation, repairs MySQL header/blob types, strictly validates bulk manifests and stored metadata, and bounds legacy readers, filesystem I/O, exporters, and lock queues. Prevents rejected, stale, or caller-written Block Headers Service Merkle roots from becoming authoritative; validates and bounds its canonical header responses; validates ChainTracks construction limits; and makes library logging opt-in. Makes ChainTracks startup failures awaited and retryable, destruction complete, event subscriptions bounded and isolated, provider results and diagnostics safe, asynchronous validation inputs owned, durable download and cache mutations cross-process serialized, low-level header/work primitives canonical, and destructive migration failures visible. Commits built-in bulk-file replacements atomically, keeps memory behind durable writes, rejects remote local identities and inconsistent event topology, and bounds remote-client subscriptions, timers, metadata, and diagnostics. Independently validates and bounds wallet-monitor reorganization work, coalesces prepared-proof invalidation, cleans up partial subscriptions, and contains host callback failures. Serializes and bounds Arcade SSE admission and cursor commits, prevents successor dispatch after failure, and repairs monitor/daemon startup, task setup, diagnostic, and teardown lifecycle. Binds every user-scoped synchronization subquery and storage-identity migration value rather than interpolating runtime values into SQL. Restores transactional SQLite schema migrations so interrupted DDL and its migration journal entry roll back together, while restoring foreign-key enforcement on success or failure. Keeps cold raw-transaction reads on the caller-owned database transaction without caching uncommitted settings or starting background work; permits absent optional inputBEEF. Rejects missing output-basket mappings during sync and applies valid newer basket changes.", - "migration": "No consumer, wire, or database migration is required for canonical proof validation and retry handling. Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. IndexedDB upgrades automatically to version 6 with a non-unique transaction-ID/user index, preserving existing data and duplicate transaction IDs. Older clients requesting schema version 6 cannot reopen the upgraded database; retain a compatible client for local backups. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing actions, ordinary noSend calls, permission modules, UMP v3 tokens, and active WAB accounts require no client migration. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 6. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share one preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but applications must store each complete snapshot in an OS Keychain, hardware-backed keystore, or comparably trusted secret store because possession of it grants wallet access. Apply every ChainTracks Knex migration before serving traffic. The MySQL repair clears only the rebuildable live-header cache while widening legacy truncating identifier columns to VARCHAR(64), retains authenticated bulk data, and adds the transaction lock row; allow the tracker to repopulate live headers before declaring it healthy. To downgrade, stop all ChainTracks writes, take and verify a database and authenticated-bulk-data backup, then use the current ChaintracksKnexMigrations source to roll down only the 2026-09-17 repair migration ledger entry. Its down step intentionally leaves the repaired VARCHAR(64) and LONGBLOB schema, chaintracks_state lock row, and authenticated bulk files intact. Start older code only after validating that retained schema and data in a non-production copy; never roll down the initial migration, recreate the tables, or restore truncating VARBINARY(32) identifier columns. Existing ChainTracks wire and public API contracts are unchanged. Existing logging integrations remain source compatible, but applications that relied on implicit console output must supply the optional logging callback explicitly. Configure durable download and cache lock timeouts deliberately; neither crash-abandoned lock is reclaimed automatically, so prove no writer remains before removing only the affected lock directory. Custom ChaintracksStorageBulkFileApi implementations must add atomic replaceBulkFiles support before multi-file reconciliation or replacement; older custom adapters now fail closed for those operations. MonitorOptions.maxQueuedDeactivatedHeaders is additive and defaults to 4096; lower it for constrained hosts. Arcade SSE event, pending-count, and pending-byte limits are additive and default to 262144 bytes, 64 events, and 4194304 bytes; lower them for constrained hosts. MonitorOptions.logging and ArcSSEClientOptions.log are additive and replace prior implicit library console output when observability is required. The migration-atomicity fix is included in the existing unpublished 2.13.2 candidate and requires no new schema migration. It prevents future partial migrations; a database already left with unjournaled schema objects by an older version still needs operator-reviewed recovery from a verified backup or an exact schema/journal reconciliation. Never delete migration journal rows or wallet data blindly. These compatible storage fixes are included in the existing unpublished 2.13.2 candidate and require no schema migration. Retry a rejected sync page only after its basket mapping is available. A previously lost basket can be restored by an authoritative newer source update; same-time and older rows never undo a local relinquishment." + "releaseType": "minor", + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.13.2", - "releaseType": "none", - "summary": "Adds optional getValidatedMerklePath provider failover with independent per-call provider traversal. Adds conservative initial sync pages and per-copy adaptation to committed page latency, with proof-work limits independent of byte budgets. Adds negotiated bounded sync transfers, full-frame integrity verification, resumable staged uploads and checkpoint replay protection for oversized records. Adds IndexedDB schema version 6 and bounded sync identity/relation and indexed proof-batch lookups to avoid full-wallet scans during large local restores. Adds negotiated compact committed sync checkpoints and faster binary JSON parsing without scalar-byte reviver callbacks. Adds adaptive wallet-storage sync reads, optional progress totals, disambiguated checkpoints, bounded HTTP 413 recovery, and MySQL/SQLite source indexes while retaining legacy peer compatibility. Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 6 lifecycle state, remote storage capability negotiation, default monitor coordination, interruption-safe WAB registration recovery, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. Carries the spending-authorization, certificate-signature, complete-accounting, credential-transport, and credential-log security corrections into the browser package while documenting snapshots as wallet-equivalent secrets. Aligns the development-only Vitest runner and V8 coverage provider at 4.1.11; consumer runtime behavior is unchanged.", - "migration": "Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. IndexedDB upgrades automatically to version 6 with a non-unique transaction-ID/user index, preserving existing data and duplicate transaction IDs. Older clients requesting schema version 6 cannot reopen the upgraded database; retain a compatible client for local backups. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing browser actions, permission modules, UMP v3 tokens, and active WAB accounts require no client migration; IndexedDB upgrades automatically. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but store each complete snapshot only through browser or extension storage backed by an OS Keychain or comparably trusted secret store." + "releaseType": "minor", + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract.", + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.13.2", - "releaseType": "none", - "summary": "Adds optional getValidatedMerklePath provider failover with independent per-call provider traversal. Adds conservative initial sync pages and per-copy adaptation to committed page latency, with proof-work limits independent of byte budgets. Adds negotiated bounded sync transfers, full-frame integrity verification, resumable staged uploads and checkpoint replay protection for oversized records. Adds negotiated compact committed sync checkpoints and faster binary JSON parsing without scalar-byte reviver callbacks. Adds adaptive wallet-storage sync reads, optional progress totals, disambiguated checkpoints, bounded HTTP 413 recovery, and MySQL/SQLite source indexes while retaining legacy peer compatibility. Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, interruption-safe WAB registration recovery, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. Carries the spending-authorization, certificate-signature, complete-accounting, credential-transport, and credential-log security corrections into the React Native package while documenting snapshots as wallet-equivalent secrets. Aligns the development-only Vitest runner and V8 coverage provider at 4.1.11; consumer runtime behavior is unchanged.", - "migration": "Custom WalletServices implementations may omit getValidatedMerklePath; stale RPC proof recovery then uses one fully validated getMerklePath lookup. Sync transfer support requires the 2.13.0 candidate or a later release containing it on the client and each relevant provider; published 2.12.0 does not contain this extension. Providers must run additive migration 2026-09-09-001 for two bounded staging tables before advertising transfer version 1; syncTransfers: false supports a mixed-version rollout. For rollback, stop transfer traffic and use the new migration source to reverse only this staging migration and its ledger entry before restarting older code; preserve all current wallet records. Existing ordinary-page and export file contracts are retained. Compact checkpoints are advertised through runtime settings; older providers retain the full-state path. No ID-map data is removed and existing wire defaults remain compatible. Existing sync peers remain compatible; additive fields are optional. Run the normal Knex migration for source indexes. Only idempotent getSyncChunk reads retry after HTTP 413. Existing mobile actions, permission modules, UMP v3 tokens, and active WAB accounts require no client migration. Deploy the additive WAB registration-status migration and WAB routes before relying on interrupted-signup recovery; older servers and clients retain their prior wire behavior. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Replace non-loopback HTTP storage and Arcade SSE endpoints with HTTPS. Snapshot APIs and formats are unchanged, but store each complete snapshot in the iOS Keychain, Android Keystore-backed encrypted storage, or a comparably trusted secret store." + "releaseType": "minor", + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies.", + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate." }, { "name": "create-bsv-app", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index e2b31321e..8d83f341d 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -14,11 +14,11 @@ "run": "https://github.com/BSV-blockchain/ts-stack/actions/runs/30144812565" }, "conformance": { - "passed": 6488, + "passed": 6494, "skipped": 211, - "total": 6699, - "vectorFiles": 77, - "run": "Local: pnpm --filter @bsv/conformance-runner-ts test (2026-09-23); 6488 vector cases passed plus 2 metadata/wire tests, 211 unchanged governed skips; adds 5 required BRC-104 response byte vectors" + "total": 6705, + "vectorFiles": 78, + "run": "Local: pnpm --filter @bsv/conformance-runner-ts test (2026-09-23); 6494 vector cases passed plus 2 metadata/wire tests, 211 unchanged governed skips; adds 6 required BRC-118 exact body/Content-Type and BRC-104 request preimage vectors verified by an independent Python writer/reader" }, "testExceptions": { "explicitSkipDeclarations": 60, @@ -300,36 +300,36 @@ "@bsv/chirp": "0.1.2", "@bsv/lch": "0.2.0", "@bsv/air-gap": "0.1.3", - "@bsv/amountinator": "2.1.6", - "@bsv/wallet-helper": "0.1.8", + "@bsv/amountinator": "2.1.7", + "@bsv/wallet-helper": "0.1.9", "create-bsv-app": "1.1.2", "@bsv/did": "0.2.6", - "@bsv/did-client": "1.3.2", - "@bsv/fund-wallet": "1.5.2", - "@bsv/simple": "0.6.0", + "@bsv/did-client": "1.3.3", + "@bsv/fund-wallet": "1.5.3", + "@bsv/simple": "0.6.1", "@bsv/templates": "1.10.2", "@bsv/authsocket": "2.1.8", "@bsv/authsocket-client": "2.1.7", - "@bsv/message-box-client": "2.5.2", + "@bsv/message-box-client": "2.6.0", "@bsv/paymail": "2.4.9", - "@bsv/402-pay": "0.3.2", + "@bsv/402-pay": "0.3.3", "@bsv/auth": "0.1.5", - "@bsv/auth-express-middleware": "2.2.5", - "@bsv/payment-express-middleware": "2.1.7", + "@bsv/auth-express-middleware": "2.3.0", + "@bsv/payment-express-middleware": "2.2.0", "@bsv/teranode-listener": "1.1.6", "@bsv/gasp": "1.3.7", "@bsv/overlay": "2.6.1", - "@bsv/overlay-discovery-services": "2.2.5", - "@bsv/overlay-express": "2.7.2", + "@bsv/overlay-discovery-services": "2.2.6", + "@bsv/overlay-express": "2.7.3", "@bsv/overlay-topics": "1.8.4", - "@bsv/sdk": "2.8.0", + "@bsv/sdk": "2.9.0", "@bsv/verifast": "0.3.6", "@bsv/btms": "1.2.3", "@bsv/btms-permission-module": "1.2.1", "@bsv/ecpm-permission-module": "0.1.1", "@bsv/wallet-relay": "0.5.1", - "@bsv/wallet-toolbox-client": "2.13.2", - "@bsv/wallet-toolbox-mobile": "2.13.2", - "@bsv/wallet-toolbox": "2.13.2" + "@bsv/wallet-toolbox-client": "2.14.0", + "@bsv/wallet-toolbox-mobile": "2.14.0", + "@bsv/wallet-toolbox": "2.14.0" } } diff --git a/packages/helpers/amountinator/README.md b/packages/helpers/amountinator/README.md index 792c09900..513a42668 100644 --- a/packages/helpers/amountinator/README.md +++ b/packages/helpers/amountinator/README.md @@ -79,3 +79,10 @@ Supported currencies: `BSV`, `SATS`, `USD`, `EUR`, `GBP`, `JPY`, `CNY`, `INR`, ` ## License This package is released under the [Open BSV License Version 6](./LICENSE.txt). + +## Next dependency release candidate + +This candidate refreshes the packed first-party dependency ranges for the next +wallet interoperability release. It adds no independent API or wire-format +change. Adopt after the new dependency graph is published; current wallet +releases retain their existing published pins. diff --git a/packages/helpers/amountinator/package.json b/packages/helpers/amountinator/package.json index ab20c6497..eb7928a64 100644 --- a/packages/helpers/amountinator/package.json +++ b/packages/helpers/amountinator/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/amountinator", - "version": "2.1.6", + "version": "2.1.7", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/helpers/bsv-wallet-helper/README.md b/packages/helpers/bsv-wallet-helper/README.md index c10af7bb7..5b4d2c1f4 100644 --- a/packages/helpers/bsv-wallet-helper/README.md +++ b/packages/helpers/bsv-wallet-helper/README.md @@ -21,28 +21,30 @@ npm install @bsv/wallet-helper ### Transaction Builder #### `TransactionBuilder` + Fluent transaction builder that simplifies creating BSV transactions with a clean, chainable API. ```typescript -import { TransactionBuilder } from '@bsv/wallet-helper'; +import { TransactionBuilder } from '@bsv/wallet-helper' // Simple P2PKH transaction with metadata -const result = await new TransactionBuilder(wallet, "Payment to Bob") - .addP2PKHOutput({ publicKey: bobPublicKey, satoshis: 1000, description: "Payment" }) - .addOpReturn(['APP_ID', JSON.stringify({ memo: 'Thanks!' })]) - .build(); +const result = await new TransactionBuilder(wallet, 'Payment to Bob') + .addP2PKHOutput({ publicKey: bobPublicKey, satoshis: 1000, description: 'Payment' }) + .addOpReturn(['APP_ID', JSON.stringify({ memo: 'Thanks!' })]) + .build() -console.log(`Transaction created: ${result.txid}`); +console.log(`Transaction created: ${result.txid}`) // Preview mode - see what will be sent without executing const preview = await new TransactionBuilder(wallet) .addP2PKHOutput({ publicKey: alicePublicKey, satoshis: 5000 }) - .build({ preview: true }); + .build({ preview: true }) -console.log('Transaction preview:', preview); +console.log('Transaction preview:', preview) ``` **Features:** + - Fluent API with method chaining - Support for P2PKH, Ordinal P2PKH, and custom outputs - **Automatic BRC-29 derivation** - omit addressOrParams to use secure random key derivation @@ -56,24 +58,26 @@ console.log('Transaction preview:', preview); 📖 **[Complete Documentation](./docs/transaction-builder.md)** **Example with automatic change:** + ```typescript // Change is automatically calculated: inputs - outputs - fees -await new TransactionBuilder(wallet, "Payment with change") - .addP2PKHInput({ sourceTransaction, sourceOutputIndex: 0, walletParams, description: "UTXO" }) - .addP2PKHOutput({ publicKey: recipientPublicKey, satoshis: 5000, description: "Payment" }) - .addChangeOutput({ walletParams, description: "Change" }) // Satoshis calculated automatically! - .build(); +await new TransactionBuilder(wallet, 'Payment with change') + .addP2PKHInput({ sourceTransaction, sourceOutputIndex: 0, walletParams, description: 'UTXO' }) + .addP2PKHOutput({ publicKey: recipientPublicKey, satoshis: 5000, description: 'Payment' }) + .addChangeOutput({ walletParams, description: 'Change' }) // Satoshis calculated automatically! + .build() ``` **Example with BRC-29 auto-derivation, basket, and customInstructions:** + ```typescript // Omit publicKey/walletParams to use automatic BRC-29 derivation // Derivation info is automatically added to customInstructions -await new TransactionBuilder(wallet, "Auto-derived transaction") - .addP2PKHOutput({ satoshis: 1000, description: "Payment" }) // Uses BRC-29 derivation - .basket("my-basket") // Set basket for this output - .customInstructions("app-specific-data") // Append custom instructions - .build(); +await new TransactionBuilder(wallet, 'Auto-derived transaction') + .addP2PKHOutput({ satoshis: 1000, description: 'Payment' }) // Uses BRC-29 derivation + .basket('my-basket') // Set basket for this output + .customInstructions('app-specific-data') // Append custom instructions + .build() // The output will have customInstructions with both app data and derivation info ``` @@ -81,52 +85,54 @@ await new TransactionBuilder(wallet, "Auto-derived transaction") ### Script Templates #### `WalletP2PKH` + Wallet-compatible Pay-to-Public-Key-Hash template. ```typescript -import { WalletP2PKH, type WalletDerivationParams } from '@bsv/wallet-helper'; +import { WalletP2PKH, type WalletDerivationParams } from '@bsv/wallet-helper' // Option 1: Direct public key -const p2pkh = new WalletP2PKH(); -const lockingScript = await p2pkh.lock({ publicKey: publicKeyHex }); +const p2pkh = new WalletP2PKH() +const lockingScript = await p2pkh.lock({ publicKey: publicKeyHex }) // Option 2: With BRC-100 wallet -const p2pkh = new WalletP2PKH(wallet); +const p2pkh = new WalletP2PKH(wallet) const lockingScript = await p2pkh.lock({ walletParams: { protocolID: [2, 'p2pkh'], keyID: '0', counterparty: 'self' } -}); +}) // Unlocking (requires wallet) const unlockingTemplate = p2pkh.unlock({ protocolID: [2, 'p2pkh'], keyID: '0', counterparty: 'self' -}); +}) ``` #### `WalletOrdP2PKH` + Wallet-compatible template for 1Sat Ordinals with inscription and MAP metadata support. ```typescript -import { WalletOrdP2PKH, type Inscription, type MAP } from '@bsv/wallet-helper'; +import { WalletOrdP2PKH, type Inscription, type MAP } from '@bsv/wallet-helper' // Create ordinal with inscription and metadata -const ordP2pkh = new WalletOrdP2PKH(wallet); +const ordP2pkh = new WalletOrdP2PKH(wallet) const inscription: Inscription = { dataB64: Buffer.from('Hello, Ordinals!').toString('base64'), contentType: 'text/plain' -}; +} const metadata: MAP = { app: 'my-app', type: 'greeting', author: 'Satoshi' -}; +} const lockingScript = await ordP2pkh.lock({ walletParams: { @@ -136,7 +142,7 @@ const lockingScript = await ordP2pkh.lock({ }, inscription, metadata -}); +}) ``` #### `WalletOrdLock` @@ -144,6 +150,7 @@ const lockingScript = await ordP2pkh.lock({ Wallet-compatible marketplace listing template for ordinals. An OrdLock output represents a listing with two spend paths: + - **Cancel** (seller): seller cancels their own listing using a wallet signature. - **Purchase** (buyer): buyer purchases the listing; the unlocking script commits to the final transaction outputs. @@ -167,7 +174,7 @@ await new TransactionBuilder(sellerWallet, 'Create listing') // Purchase listing (output ordering matters; see docs) await new TransactionBuilder(buyerWallet, 'Purchase listing') .addOrdLockInput({ sourceTransaction: listingTx, sourceOutputIndex: 0, kind: 'purchase' }) - .addP2PKHOutput({ publicKey: buyerPubKey, satoshis: 1 }) // Output 0: ordinal to buyer + .addP2PKHOutput({ publicKey: buyerPubKey, satoshis: 1 }) // Output 0: ordinal to buyer .addP2PKHOutput({ publicKey: sellerPubKey, satoshis: 1000 }) // Output 1: payment to seller .options({ randomizeOutputs: false }) .build() @@ -180,37 +187,40 @@ await new TransactionBuilder(buyerWallet, 'Purchase listing') ### Types #### `WalletDerivationParams` + Parameters for deriving keys from a BRC-100 wallet. ```typescript type WalletDerivationParams = { - protocolID: WalletProtocol; // e.g., [2, 'p2pkh'] - keyID: string; // e.g., '0' - counterparty: WalletCounterparty; // e.g., 'self' -}; + protocolID: WalletProtocol // e.g., [2, 'p2pkh'] + keyID: string // e.g., '0' + counterparty: WalletCounterparty // e.g., 'self' +} ``` **Note:** When wallet derivation parameters are omitted, the library uses the BRC-29 derivation scheme by default (using `brc29ProtocolID` from `@bsv/wallet-toolbox-client` and a randomly generated keyID), with `counterparty` defaulting to `'self'`. #### `Inscription` + 1Sat Ordinal inscription data. ```typescript type Inscription = { - dataB64: string; // Base64 encoded file data - contentType: string; // MIME type (e.g., 'image/png', 'text/plain') -}; + dataB64: string // Base64 encoded file data + contentType: string // MIME type (e.g., 'image/png', 'text/plain') +} ``` #### `MAP` + MAP (Magic Attribute Protocol) metadata for ordinals. ```typescript type MAP = { - app: string; // Application identifier (required) - type: string; // Data type identifier (required) - [key: string]: string; // Additional custom fields -}; + app: string // Application identifier (required) + type: string // Data type identifier (required) + [key: string]: string // Additional custom fields +} ``` ### Utilities @@ -220,6 +230,7 @@ Helper functions for wallet creation, transaction signing, script manipulation, 📖 **[Complete Utilities Documentation](./docs/utilities.md)** Includes: + - **Wallet Creation**: `makeWallet()` for creating BRC-100 wallets - **Transaction Signing**: `calculatePreimage()` for signature generation - **Script Utilities**: `addOpReturnData()` for adding metadata @@ -235,39 +246,39 @@ Includes: ### ✅ Correct Usage ```typescript -const wallet = await makeWallet('test', storageURL, privateKeyHex); -const p2pkh = new WalletP2PKH(wallet); +const wallet = await makeWallet('test', storageURL, privateKeyHex) +const p2pkh = new WalletP2PKH(wallet) const walletParams = { protocolID: [2, 'p2pkh'] as WalletProtocol, keyID: '0', counterparty: 'self' as WalletCounterparty -}; +} // Lock with wallet derivation -const lockingScript = await p2pkh.lock({ walletParams }); +const lockingScript = await p2pkh.lock({ walletParams }) // Unlock with SAME derivation params const unlockingTemplate = p2pkh.unlock({ protocolID: walletParams.protocolID, keyID: walletParams.keyID, counterparty: walletParams.counterparty -}); +}) ``` ### ❌ Incorrect Usage ```typescript // Lock with direct public key -const lockingScript = await p2pkh.lock({ publicKey: publicKeyHex }); +const lockingScript = await p2pkh.lock({ publicKey: publicKeyHex }) // Try to unlock with different derivation params // This WILL FAIL even if from same private key! const unlockingTemplate = p2pkh.unlock({ - protocolID: [2, 'different-protocol'], // Different protocol - keyID: '1', // Different keyID - counterparty: 'counterparty' // Different counterparty -}); + protocolID: [2, 'different-protocol'], // Different protocol + keyID: '1', // Different keyID + counterparty: 'counterparty' // Different counterparty +}) ``` **Why?** Each set of derivation parameters produces a different private key from the seed key -> different public key. The unlocking signature must match the exact public key hash used in the locking script. @@ -290,29 +301,29 @@ locally controlled. ```typescript // Recommended: Store params with your UTXO type MyUTXO = { - lockingScript: LockingScript; - satoshis: number; - derivationParams: WalletDerivationParams; // Store these! -}; + lockingScript: LockingScript + satoshis: number + derivationParams: WalletDerivationParams // Store these! +} // Later when spending const unlockingTemplate = p2pkh.unlock({ protocolID: utxo.derivationParams.protocolID, keyID: utxo.derivationParams.keyID, counterparty: utxo.derivationParams.counterparty -}); +}) ``` ## Examples For complete working examples, see the test files: -- **WalletP2PKH Examples**: [src/script-templates/__tests__/p2pkh.test.ts](./src/script-templates/__tests__/p2pkh.test.ts#L146) +- **WalletP2PKH Examples**: [src/script-templates/**tests**/p2pkh.test.ts](./src/script-templates/__tests__/p2pkh.test.ts#L146) - Creating and spending P2PKH transactions - Multiple inputs with wallet signing - Different signature scopes (SIGHASH_SINGLE) -- **WalletOrdP2PKH Examples**: [src/script-templates/__tests__/ordinal.test.ts](./src/script-templates/__tests__/ordinal.test.ts#L146) +- **WalletOrdP2PKH Examples**: [src/script-templates/**tests**/ordinal.test.ts](./src/script-templates/__tests__/ordinal.test.ts#L146) - Creating ordinals with inscriptions and metadata - Spending ordinal outputs - Reinscriptions (metadata-only updates) @@ -327,14 +338,14 @@ const original = await ordP2pkh.lock({ walletParams, inscription: { dataB64: largeImage, contentType: 'image/png' }, metadata: { app: 'gallery', type: 'art', owner: 'alice' } -}); +}) // Later: Update metadata only (saves transaction fees) const updated = await ordP2pkh.lock({ walletParams, // No inscription field = no file data metadata: { app: 'gallery', type: 'art', owner: 'bob', sold: 'true' } -}); +}) ``` ## License @@ -354,3 +365,10 @@ Contributions are welcome! Please open an issue or submit a pull request. This project is a work in progress and may change at any time. It is provided as-is, without any guarantees. Use at your own risk. + +## Next dependency release candidate + +This candidate refreshes the packed first-party dependency ranges for the next +wallet interoperability release. It adds no independent API or wire-format +change. Adopt after the new dependency graph is published; current wallet +releases retain their existing published pins. diff --git a/packages/helpers/bsv-wallet-helper/package.json b/packages/helpers/bsv-wallet-helper/package.json index fbb8b0969..e35a700f6 100644 --- a/packages/helpers/bsv-wallet-helper/package.json +++ b/packages/helpers/bsv-wallet-helper/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-helper", - "version": "0.1.8", + "version": "0.1.9", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/helpers/did-client/README.md b/packages/helpers/did-client/README.md index d3ee9e787..03cdebbdc 100644 --- a/packages/helpers/did-client/README.md +++ b/packages/helpers/did-client/README.md @@ -87,11 +87,11 @@ const client = new DIDClient({ ## API -| Method | Purpose | -| ----------------------------------------- | ----------------------------------------------------------------------- | +| Method | Purpose | +| ----------------------------------------- | ----------------------------------------------------------------------------- | | `createDID(serialNumber, subject, opts?)` | Mints an issuer-owned legacy token and retains the subject in wallet metadata | -| `findDID(query)` | Looks up DID records by serial number, outpoint, date range, etc. | -| `revokeDID(opts)` | Spends an existing DID UTXO, removing it from the overlay | +| `findDID(query)` | Looks up DID records by serial number, outpoint, date range, etc. | +| `revokeDID(opts)` | Spends an existing DID UTXO, removing it from the overlay | `subject` participates in wallet key derivation and is retained in the issuer's authenticated wallet metadata, but it is not present in the v1 public @@ -104,3 +104,10 @@ TS Stack first-party material is under the [Open BSV License Version 6](./LICENS The UMD bundle incorporates separately licensed SDK material; keep [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) and [LICENSES/](./LICENSES/) with the bundle. + +## Next dependency release candidate + +This candidate refreshes the packed first-party dependency ranges for the next +wallet interoperability release. It adds no independent API or wire-format +change. Adopt after the new dependency graph is published; current wallet +releases retain their existing published pins. diff --git a/packages/helpers/did-client/package.json b/packages/helpers/did-client/package.json index 68e8a61f2..5f88dde1c 100644 --- a/packages/helpers/did-client/package.json +++ b/packages/helpers/did-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/did-client", - "version": "1.3.2", + "version": "1.3.3", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/helpers/fund-wallet/README.md b/packages/helpers/fund-wallet/README.md index 2f33ad032..56e8651f7 100644 --- a/packages/helpers/fund-wallet/README.md +++ b/packages/helpers/fund-wallet/README.md @@ -161,3 +161,10 @@ The recovered pre-monorepo ISC notice is retained in - [@bsv/sdk](https://www.npmjs.com/package/@bsv/sdk) - Bitcoin SV SDK - [@bsv/wallet-toolbox](https://www.npmjs.com/package/@bsv/wallet-toolbox) - Wallet management tools - [Metanet Desktop](https://metanet.bsvb.tech) - Local BSV wallet application + +## Next dependency release candidate + +This candidate refreshes the packed first-party dependency ranges for the next +wallet interoperability release. It adds no independent API or wire-format +change. Adopt after the new dependency graph is published; current wallet +releases retain their existing published pins. diff --git a/packages/helpers/fund-wallet/package.json b/packages/helpers/fund-wallet/package.json index 278198015..a21548e99 100644 --- a/packages/helpers/fund-wallet/package.json +++ b/packages/helpers/fund-wallet/package.json @@ -1,7 +1,7 @@ { "name": "@bsv/fund-wallet", "private": false, - "version": "1.5.2", + "version": "1.5.3", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/helpers/simple/README.md b/packages/helpers/simple/README.md index 0c7ccc2f8..36147a8fb 100644 --- a/packages/helpers/simple/README.md +++ b/packages/helpers/simple/README.md @@ -155,3 +155,10 @@ const did = wallet.getDID() TS Stack first-party changes are under the [Open BSV License Version 6](./LICENSE.txt). The identified pre-monorepo source remains MIT-licensed; see [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) and [LICENSES/](./LICENSES/). + +## Next dependency release candidate + +This candidate refreshes the packed first-party dependency ranges for the next +wallet interoperability release. It adds no independent API or wire-format +change. Adopt after the new dependency graph is published; current wallet +releases retain their existing published pins. diff --git a/packages/helpers/simple/package.json b/packages/helpers/simple/package.json index c07d703b8..1349b3716 100644 --- a/packages/helpers/simple/package.json +++ b/packages/helpers/simple/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/simple", - "version": "0.6.0", + "version": "0.6.1", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/middleware/402-pay/CHANGELOG.md b/packages/middleware/402-pay/CHANGELOG.md index 1bf1d5f16..1780875c6 100644 --- a/packages/middleware/402-pay/CHANGELOG.md +++ b/packages/middleware/402-pay/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### 0.3.3 candidate — protocol selection documentation + +- Clarify that BRC-118 extends the separate SDK AuthFetch/BRC-105 middleware path. + This package's BRC-121 runtime, headers and replay behavior remain unchanged. + ### 0.3.2 candidate — replay-safe Atomic BEEF payments ### Maintenance diff --git a/packages/middleware/402-pay/README.md b/packages/middleware/402-pay/README.md index 636101b35..5e653b099 100644 --- a/packages/middleware/402-pay/README.md +++ b/packages/middleware/402-pay/README.md @@ -2,6 +2,14 @@ [BRC-121](https://github.com/bitcoin-sv/BRCs/blob/master/payments/0121.md) Simple 402 Payments -- server middleware and client for BSV micropayments over HTTP. +## Protocol boundary + +This package implements the BRC-121 `x-bsv-beef`/sender/nonce/time/vout contract. +BRC-118 extends the separate authenticated BRC-105 payment path in SDK AuthFetch +and `@bsv/payment-express-middleware`; it is not implicitly negotiated here. +See the [BRC-118 guide](../../../docs/guides/brc118-payments.md) when selecting an +integration. Existing 402-pay wire behavior is unchanged. + ## Install ```sh @@ -48,13 +56,7 @@ import { validatePayment, send402 } from '@bsv/402-pay/server' // In any HTTP handler: const requiredSatoshis = 100 -const result = await validatePayment( - req, - wallet, - requiredSatoshis, - 30_000, - sharedAtomicReplayStore -) +const result = await validatePayment(req, wallet, requiredSatoshis, 30_000, sharedAtomicReplayStore) if (!result) { send402(res, serverIdentityKey, requiredSatoshis) return diff --git a/packages/middleware/402-pay/package.json b/packages/middleware/402-pay/package.json index acb1438c9..62d4f4b2b 100644 --- a/packages/middleware/402-pay/package.json +++ b/packages/middleware/402-pay/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/402-pay", - "version": "0.3.2", + "version": "0.3.3", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/overlays/overlay-discovery-services/CHANGELOG.md b/packages/overlays/overlay-discovery-services/CHANGELOG.md index fc38a906c..f80017312 100644 --- a/packages/overlays/overlay-discovery-services/CHANGELOG.md +++ b/packages/overlays/overlay-discovery-services/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG for `@bsv/overlay-discovery-services` +## 2.2.6 (unreleased) + +- Refresh packed first-party dependency ranges for the next wallet interoperability release; no independent API migration. + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Table of Contents diff --git a/packages/overlays/overlay-discovery-services/README.md b/packages/overlays/overlay-discovery-services/README.md index e12b0273c..01233b765 100644 --- a/packages/overlays/overlay-discovery-services/README.md +++ b/packages/overlays/overlay-discovery-services/README.md @@ -168,3 +168,10 @@ Current TS Stack changes are licensed under the Open BSV License Version 6; see under the Open BSV License Version 4. Redistributors must preserve [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) and the applicable text in [`LICENSES/`](./LICENSES/). + +## Next dependency release candidate + +This candidate refreshes the packed first-party dependency ranges for the next +wallet interoperability release. It adds no independent API or wire-format +change. Adopt after the new dependency graph is published; current wallet +releases retain their existing published pins. diff --git a/packages/overlays/overlay-discovery-services/package.json b/packages/overlays/overlay-discovery-services/package.json index 3221cc09f..260ec6cb9 100644 --- a/packages/overlays/overlay-discovery-services/package.json +++ b/packages/overlays/overlay-discovery-services/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay-discovery-services", - "version": "2.2.5", + "version": "2.2.6", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/overlays/overlay-express/CHANGELOG.md b/packages/overlays/overlay-express/CHANGELOG.md index 07aefa1ff..a53c2410f 100644 --- a/packages/overlays/overlay-express/CHANGELOG.md +++ b/packages/overlays/overlay-express/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG for `@bsv/overlay-express` +## 2.7.3 (unreleased) + +- Refresh packed first-party dependency ranges for the next wallet interoperability release; no independent API migration. + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Table of Contents diff --git a/packages/overlays/overlay-express/README.md b/packages/overlays/overlay-express/README.md index c1f9ddcf5..42fe3c837 100644 --- a/packages/overlays/overlay-express/README.md +++ b/packages/overlays/overlay-express/README.md @@ -431,3 +431,10 @@ under the Open BSV License Version 4. Redistributors must preserve [`LICENSES/`](./LICENSES/). Thank you for being a part of the BSV Blockchain Overlay Express Project. Let's build the future of BSV Blockchain together! + +## Next dependency release candidate + +This candidate refreshes the packed first-party dependency ranges for the next +wallet interoperability release. It adds no independent API or wire-format +change. Adopt after the new dependency graph is published; current wallet +releases retain their existing published pins. diff --git a/packages/overlays/overlay-express/package.json b/packages/overlays/overlay-express/package.json index f6a62c5a2..ffb380d80 100644 --- a/packages/overlays/overlay-express/package.json +++ b/packages/overlays/overlay-express/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay-express", - "version": "2.7.2", + "version": "2.7.3", "sideEffects": false, "engines": { "node": ">=22" From 94b01333aff7f18a634c3050350146a97dcb805d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 05:41:39 -0700 Subject: [PATCH 005/127] ci: scan the independent BRC-118 Python oracle with CodeQL --- .github/workflows/codeql.yml | 7 +++---- scripts/codeql-config.test.mjs | 13 ++++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4e023f161..cea6b88e1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,10 +32,9 @@ jobs: language: - actions - javascript-typescript - # The only Python files are deterministic OpenAPI output under - # conformance/generated/**. The CodeQL config excludes that owned - # generated boundary, while codegen CI verifies its specs, locked - # generator, and output. + # Scan the independently authored BRC-118 conformance oracle. + # Generated OpenAPI clients remain excluded by the owned registry. + - python steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/scripts/codeql-config.test.mjs b/scripts/codeql-config.test.mjs index 61c464ae9..61c5ae80b 100644 --- a/scripts/codeql-config.test.mjs +++ b/scripts/codeql-config.test.mjs @@ -63,11 +63,14 @@ test('advanced CodeQL preserves authored languages, events, permissions, and req .filter(Boolean) .filter(path => !generatedBoundaries.some(boundary => matchesGeneratedBoundary(path, boundary))) - assert.deepEqual(readIndentedList(workflow, 'language', 8), ['actions', 'javascript-typescript']) - assert.deepEqual( - authoredPythonFiles, - [], - 'authored Python requires restoring the Python CodeQL lane' + assert.deepEqual(readIndentedList(workflow, 'language', 8), [ + 'actions', + 'javascript-typescript', + ...(authoredPythonFiles.length > 0 ? ['python'] : []) + ]) + assert.ok( + authoredPythonFiles.includes('conformance/runner/scripts/brc118-vectors.py'), + 'the independent BRC-118 oracle must remain inside the authored CodeQL boundary' ) assert.match(workflow, /^ push:\n branches: \[main\]$/m) assert.match(workflow, /^ pull_request:\n branches: \[main\]$/m) From 1d0fe1e36d3558c765b10eb8d0ae6bec930377e6 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 05:54:12 -0700 Subject: [PATCH 006/127] refactor: simplify payment and sync boundaries and resolve security findings --- .../src/Utils/peerPayTransaction.ts | 4 +- .../auth-express-middleware/src/index.ts | 29 +- .../payment-express-middleware/package.json | 7 +- .../src/__tests/Brc118.integration.test.ts | 29 +- .../src/__tests/MultipartPayment.test.ts | 21 ++ .../payment-express-middleware/src/index.ts | 119 ++++--- .../src/multipartPayment.ts | 181 ++++++----- .../test/brc118-browser.mjs | 13 +- packages/sdk/src/auth/clients/AuthFetch.ts | 301 ++++++++++-------- .../sdk/src/auth/utils/paymentTransport.ts | 23 +- .../client/test/sync-browser.mjs | 4 +- .../client/test/sync-browser.ts | 2 +- .../src/storage/WalletStorageManager.ts | 2 +- .../src/storage/sync/StorageAccessQueue.ts | 2 +- .../src/storage/sync/SyncPageBudget.ts | 2 +- .../src/storage/sync/syncFailure.ts | 3 +- .../src/storage/sync/syncSession.ts | 77 +++-- pnpm-lock.yaml | 3 + 18 files changed, 502 insertions(+), 320 deletions(-) diff --git a/packages/messaging/message-box-client/src/Utils/peerPayTransaction.ts b/packages/messaging/message-box-client/src/Utils/peerPayTransaction.ts index 1cce68141..f94b8ac79 100644 --- a/packages/messaging/message-box-client/src/Utils/peerPayTransaction.ts +++ b/packages/messaging/message-box-client/src/Utils/peerPayTransaction.ts @@ -15,7 +15,9 @@ export function decodePeerPayTransaction(value: string, maximumBytes: number): n value.length > Math.ceil(maximumBytes / 3) * 4 ) invalid() - const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0 + let padding = 0 + if (value.endsWith('==')) padding = 2 + else if (value.endsWith('=')) padding = 1 const decodedLength = (value.length / 4) * 3 - padding if (decodedLength === 0 || decodedLength > maximumBytes) invalid() let last = 0 diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index ec397b48c..ce62b8334 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -2022,6 +2022,22 @@ function buildResponsePayload( return writer.toArray() } +function validateAuthMiddlewareSettings(options: AuthMiddlewareOptions): void { + const { allowUnauthenticated, logLevel, onCertificatesReceived } = options + if (allowUnauthenticated !== undefined && typeof allowUnauthenticated !== 'boolean') { + throw new TypeError('allowUnauthenticated must be a boolean.') + } + if (options.captureRawBody !== undefined && typeof options.captureRawBody !== 'boolean') { + throw new TypeError('captureRawBody must be a boolean.') + } + if (logLevel !== undefined && !(['debug', 'info', 'warn', 'error'] as const).includes(logLevel)) { + throw new TypeError('logLevel must be debug, info, warn, or error.') + } + if (onCertificatesReceived !== undefined && typeof onCertificatesReceived !== 'function') { + throw new TypeError('onCertificatesReceived must be a function.') + } +} + /** * Creates an Express middleware that handles authentication via BSV-SDK. * @@ -2054,18 +2070,7 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions): RequestHan } throw new TypeError('You must configure the auth middleware with a wallet.') } - if (allowUnauthenticated !== undefined && typeof allowUnauthenticated !== 'boolean') { - throw new TypeError('allowUnauthenticated must be a boolean.') - } - if (options.captureRawBody !== undefined && typeof options.captureRawBody !== 'boolean') { - throw new TypeError('captureRawBody must be a boolean.') - } - if (logLevel !== undefined && !(['debug', 'info', 'warn', 'error'] as const).includes(logLevel)) { - throw new TypeError('logLevel must be debug, info, warn, or error.') - } - if (onCertificatesReceived !== undefined && typeof onCertificatesReceived !== 'function') { - throw new TypeError('onCertificatesReceived must be a function.') - } + validateAuthMiddlewareSettings(options) const transport = new ExpressTransport( allowUnauthenticated ?? false, diff --git a/packages/middleware/payment-express-middleware/package.json b/packages/middleware/payment-express-middleware/package.json index 501e46e23..8f66099f8 100644 --- a/packages/middleware/payment-express-middleware/package.json +++ b/packages/middleware/payment-express-middleware/package.json @@ -70,16 +70,17 @@ "@types/jest": "^30.0.0", "@types/node": "^26.1.2", "@typescript/native": "npm:typescript@7.0.2", + "esbuild": "0.28.1", "express": "^5.2.1", + "express-rate-limit": "8.6.1", "fast-check": "^4.9.0", "jest": "^30.4.2", "oxlint": "^1.76.0", + "puppeteer-core": "^25.4.0", "ts-jest": "^29.4.12", "ts2md": "^0.2.8", "tsdown": "0.22.14", - "typescript": "npm:@typescript/typescript6@6.0.2", - "esbuild": "0.28.1", - "puppeteer-core": "^25.4.0" + "typescript": "npm:@typescript/typescript6@6.0.2" }, "bugs": { "url": "https://github.com/bsv-blockchain/ts-stack/issues" diff --git a/packages/middleware/payment-express-middleware/src/__tests/Brc118.integration.test.ts b/packages/middleware/payment-express-middleware/src/__tests/Brc118.integration.test.ts index 9bd16ff18..a41b496f5 100644 --- a/packages/middleware/payment-express-middleware/src/__tests/Brc118.integration.test.ts +++ b/packages/middleware/payment-express-middleware/src/__tests/Brc118.integration.test.ts @@ -1,4 +1,5 @@ import express from 'express' +import { rateLimit } from 'express-rate-limit' import { createServer, request as httpRequest, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { @@ -93,6 +94,7 @@ async function fixture( return { txid: tx.id('hex'), tx: tx.toAtomicBEEF() } }) const app = express() + app.use(rateLimit({ windowMs: 60_000, limit: 100 })) if (options.raw === false) app.use(express.json()) app.use(createAuthMiddleware({ wallet: serverWallet, captureRawBody: options.raw !== false })) app.use( @@ -135,10 +137,9 @@ async function fixture( }) req.on('end', () => { if (res.writableEnded) return - const destination = new URL(req.url!, upstream) const forward = httpRequest( - destination, - { method: req.method, headers: req.headers }, + upstream, + { path: req.url, method: req.method, headers: req.headers }, upstreamResponse => { res.writeHead(upstreamResponse.statusCode!, upstreamResponse.headers) upstreamResponse.pipe(res) @@ -160,6 +161,28 @@ async function fixture( } describe('BRC-118 through signed HTTP and a 6 KiB-header proxy', () => { + it('keeps absolute request targets on the fixed test upstream', async () => { + let redirectedRequests = 0 + const otherOrigin = await listen( + createServer((_req, res) => { + redirectedRequests++ + res.writeHead(204).end() + }) + ) + const { origin, serverWallet } = await fixture() + const status = await new Promise((resolve, reject) => { + const request = httpRequest(origin, { path: `${otherOrigin}/paid` }, response => { + response.resume() + response.on('end', () => resolve(response.statusCode)) + }) + request.on('error', reject) + request.end() + }) + expect(status).not.toBe(204) + expect(redirectedRequests).toBe(0) + expect(serverWallet.internalizeAction).not.toHaveBeenCalled() + }) + it.each([ ['application/json; charset=utf-8', Buffer.from('{ "snow": "雪" }\n')], ['application/octet-stream', Buffer.from([0, 128, 255, 13, 10, 0])], diff --git a/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts b/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts index c8b5bb716..0254ab035 100644 --- a/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts +++ b/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts @@ -110,6 +110,27 @@ describe('authenticated BRC-118 extraction', () => { expect(() => parse(wire(part('x-bsv-payment', payment)), contentType)).toThrow() }) + it.each([null, 'not raw bytes', [], { length: 0, buffer: new ArrayBuffer(0) }])( + 'rejects a tampered raw-body type %# before reading framing', + value => { + expect(() => parse(value as unknown as Uint8Array)).toThrow('Malformed multipart payment') + } + ) + + it.each([ + 'Content-Type', + ': application/json', + 'Content Type: application/json', + `Content-Type:${' '.repeat(2050)}` + ])('rejects malformed or oversized header lines %#', header => { + const bytes = Buffer.from( + wire(part('x-bsv-payment', payment)) + .toString() + .replace('Content-Type: application/json', header) + ) + expect(() => parse(bytes)).toThrow('Malformed multipart payment') + }) + it('bounds body, payment, and part headers', () => { const bytes = wire(part('x-bsv-payment', payment)) expect(() => parseMultipartPayment(bytes, type, bytes.length - 1, 1000)).toThrow('limit') diff --git a/packages/middleware/payment-express-middleware/src/index.ts b/packages/middleware/payment-express-middleware/src/index.ts index fcdb9ecd5..0b423a8ea 100644 --- a/packages/middleware/payment-express-middleware/src/index.ts +++ b/packages/middleware/payment-express-middleware/src/index.ts @@ -52,7 +52,9 @@ function isCanonicalBase64(value: string): boolean { } // Check pad bits without decoding or using a repeated-group regex, whose // engine stack can overflow on the large BEEFs multipart was designed for. - const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0 + let padding = 0 + if (value.endsWith('==')) padding = 2 + else if (value.endsWith('=')) padding = 1 const last = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.indexOf( value[value.length - padding - 1] ) @@ -214,6 +216,70 @@ async function issuePaymentChallenge( } } +interface PaymentInputLimits { + enableMultipart: boolean + maxPaymentHeaderBytes: number + maxPaymentBodyBytes: number + maxPaymentBytes: number +} + +function restoreApplicationPayload( + req: PaymentRequest, + parsed: ReturnType +): void { + req.rawBody = parsed.body + req.body = decodePaymentPayload(parsed.body, parsed.contentType) + delete req.headers['content-type'] + delete req.headers['content-length'] + delete req.headers['transfer-encoding'] + if (parsed.contentType !== undefined) req.headers['content-type'] = parsed.contentType + if (parsed.body !== undefined) req.headers['content-length'] = String(parsed.body.length) +} + +function extractPaymentInput( + req: PaymentRequest, + res: Response, + multipart: boolean, + limits: PaymentInputLimits +): { rawPayment: string | null | undefined; paymentLimit: number } | undefined { + const { enableMultipart, maxPaymentHeaderBytes, maxPaymentBodyBytes, maxPaymentBytes } = limits + const contentType = req.headers['content-type'] + const rawPayment = paymentHeader(req) + const headerInput = { rawPayment, paymentLimit: maxPaymentHeaderBytes } + if (!enableMultipart || typeof contentType !== 'string' || !isMultipartPaymentType(contentType)) + return headerInput + if (!multipart || rawPayment !== undefined || !(req.body instanceof Uint8Array)) { + sendError( + res, + 400, + 'ERR_MALFORMED_PAYMENT', + 'Multipart payments require raw authentication and exactly one payment source.' + ) + return undefined + } + try { + const parsed = parseMultipartPayment( + req.body, + contentType, + maxPaymentBodyBytes, + maxPaymentBytes + ) + restoreApplicationPayload(req, parsed) + return { rawPayment: parsed.paymentJSON, paymentLimit: maxPaymentBytes } + } catch (error) { + if (error instanceof MissingMultipartPayment) return headerInput + const status = + error instanceof PaymentTransportError && error.code === 'ERR_PAYMENT_SIZE' ? 413 : 400 + sendError( + res, + status, + 'ERR_MALFORMED_PAYMENT', + 'The multipart payment is malformed or exceeds its limit.' + ) + return undefined + } +} + /** * Creates middleware that enforces a BRC-29 wallet payment after BRC-103 auth. */ @@ -273,49 +339,14 @@ export function createPaymentMiddleware(options: PaymentMiddlewareOptions): Requ } const multipart = enableMultipart && paymentRequest.auth?.supportsMultipart === true - const contentType = paymentRequest.headers['content-type'] - let rawPayment = paymentHeader(paymentRequest) - let paymentLimit = maxPaymentHeaderBytes - if (enableMultipart && typeof contentType === 'string' && isMultipartPaymentType(contentType)) { - if (!multipart || rawPayment !== undefined || !(paymentRequest.body instanceof Uint8Array)) { - sendError( - res, - 400, - 'ERR_MALFORMED_PAYMENT', - 'Multipart payments require raw authentication and exactly one payment source.' - ) - return - } - try { - const parsed = parseMultipartPayment( - paymentRequest.body, - contentType, - maxPaymentBodyBytes, - maxPaymentBytes - ) - paymentRequest.rawBody = parsed.body - paymentRequest.body = decodePaymentPayload(parsed.body, parsed.contentType) - delete paymentRequest.headers['content-type'] - delete paymentRequest.headers['content-length'] - delete paymentRequest.headers['transfer-encoding'] - if (parsed.contentType !== undefined) - paymentRequest.headers['content-type'] = parsed.contentType - if (parsed.body !== undefined) - paymentRequest.headers['content-length'] = String(parsed.body.length) - rawPayment = parsed.paymentJSON - paymentLimit = maxPaymentBytes - } catch (error) { - if (!(error instanceof MissingMultipartPayment)) { - sendError( - res, - error instanceof PaymentTransportError && error.code === 'ERR_PAYMENT_SIZE' ? 413 : 400, - 'ERR_MALFORMED_PAYMENT', - 'The multipart payment is malformed or exceeds its limit.' - ) - return - } - } - } + const input = extractPaymentInput(paymentRequest, res, multipart, { + enableMultipart, + maxPaymentHeaderBytes, + maxPaymentBodyBytes, + maxPaymentBytes + }) + if (input === undefined) return + const { rawPayment, paymentLimit } = input let requestPrice: number try { diff --git a/packages/middleware/payment-express-middleware/src/multipartPayment.ts b/packages/middleware/payment-express-middleware/src/multipartPayment.ts index 283f73f7c..e20e4da1e 100644 --- a/packages/middleware/payment-express-middleware/src/multipartPayment.ts +++ b/packages/middleware/payment-express-middleware/src/multipartPayment.ts @@ -14,6 +14,100 @@ export interface ParsedMultipartPayment { /** An ordinary, unpaid multipart application request needs the normal 402 challenge. */ export class MissingMultipartPayment extends Error {} +function malformed(): never { + throw new PaymentTransportError('ERR_PAYMENT_TRANSPORT', 'Malformed multipart payment.') +} + +interface Part { + name: string + contentType?: string + bytes: Uint8Array + next: number + final: boolean +} + +function partHeaders( + source: Buffer, + position: number +): { headers: Map; start: number } { + // Bound the header search itself, before reading any attacker-sized field. + const window = source.subarray(position, Math.min(position + 2048 + 4, source.length)) + const length = window.indexOf('\r\n\r\n') + if (length < 0 || length > 2048) malformed() + const bytes = window.subarray(0, length) + if (bytes.some(byte => byte > 126 || (byte < 32 && byte !== 13 && byte !== 10))) malformed() + const headers = new Map() + for (const line of bytes.toString('ascii').split('\r\n')) { + const colon = line.indexOf(':') + const name = line.slice(0, colon).toLowerCase() + if (colon < 1 || !/^[a-z-]+$/.test(name)) malformed() + if (headers.has(name) || (name !== 'content-disposition' && name !== 'content-type')) + malformed() + headers.set(name, line.slice(colon + 1).trim()) + } + return { headers, start: position + length + 4 } +} + +function partEnd(source: Buffer, delimiter: Buffer, start: number): number { + let end = source.indexOf(delimiter, start) + while (end !== -1) { + const suffix = source + .subarray(end + delimiter.length, end + delimiter.length + 2) + .toString('ascii') + if (suffix === '\r\n' || suffix === '--') return end + end = source.indexOf(delimiter, end + delimiter.length) + } + return malformed() +} + +function readPart(source: Buffer, delimiter: Buffer, position: number): Part { + const { headers, start } = partHeaders(source, position) + const disposition = + /^form-data;\s*name="([^"\r\n]{1,128})"(?:;\s*filename="[^"\r\n]{0,256}")?$/i.exec( + headers.get('content-disposition') ?? '' + ) + if (disposition === null) malformed() + const contentType = headers.get('content-type') + if (contentType !== undefined) paymentPayloadContentType(contentType) + const end = partEnd(source, delimiter, start) + const suffix = end + delimiter.length + const final = source.subarray(suffix, suffix + 2).toString('ascii') === '--' + const next = suffix + 2 + if (final) { + const epilogue = source.subarray(next).toString('ascii') + if (epilogue !== '' && epilogue !== '\r\n') malformed() + } + return { name: disposition[1], contentType, bytes: source.subarray(start, end), next, final } +} + +interface CollectedParts { + paymentJSON?: string + body?: Uint8Array + contentType?: string + invalid: boolean +} + +function collectPart(result: CollectedParts, part: Part, maxPaymentBytes: number): void { + if (part.name === 'x-bsv-payment') { + if ( + part.contentType === undefined || + !/^application\/json(?:;\s*charset=utf-8)?$/i.test(part.contentType) + ) + malformed() + if (part.bytes.length > maxPaymentBytes) + throw new PaymentTransportError( + 'ERR_PAYMENT_SIZE', + 'Multipart payment JSON exceeds its limit.' + ) + result.paymentJSON = toUTF8Strict(part.bytes) + } else if (part.name === 'body') { + if (part.contentType === undefined) result.invalid = true + // Own inner bytes so downstream views cannot retain or expose the outer payment part. + result.body = new Uint8Array(part.bytes) + result.contentType = part.contentType + } else result.invalid = true +} + /** A bounded two-part RFC 7578 profile. Run only after authenticating the complete raw request. */ export function parseMultipartPayment( bytes: Uint8Array, @@ -21,9 +115,7 @@ export function parseMultipartPayment( maxBodyBytes: number, maxPaymentBytes: number ): ParsedMultipartPayment { - const malformed = (): never => { - throw new PaymentTransportError('ERR_PAYMENT_TRANSPORT', 'Malformed multipart payment.') - } + if (!(bytes instanceof Uint8Array)) malformed() if (bytes.length > maxBodyBytes) throw new PaymentTransportError('ERR_PAYMENT_SIZE', 'Multipart payment body exceeds its limit.') const boundary = paymentBoundary(contentType) @@ -33,82 +125,19 @@ export function parseMultipartPayment( if (source.equals(Buffer.from(`--${boundary}--\r\n`))) throw new MissingMultipartPayment() if (!source.subarray(0, opening.length).equals(opening)) malformed() let position = opening.length - let paymentJSON: string | undefined - let body: Uint8Array | undefined - let bodyType: string | undefined let parts = 0 - let invalidPaymentProfile = false + const result: CollectedParts = { invalid: false } const seen = new Set() while (true) { if (++parts > 128) malformed() - // Bound the header search itself, before reading any attacker-sized field. - const headerWindow = source.subarray(position, Math.min(position + 2048 + 4, source.length)) - const headerLength = headerWindow.indexOf('\r\n\r\n') - if (headerLength < 0 || headerLength > 2048) malformed() - const rawHeaders = headerWindow.subarray(0, headerLength).toString('ascii') - if ( - headerWindow - .subarray(0, headerLength) - .some(byte => byte > 126 || (byte < 32 && byte !== 13 && byte !== 10)) - ) - malformed() - const headers = new Map() - for (const line of rawHeaders.split('\r\n')) { - const match = /^([A-Za-z-]+):[ \t]*(.*)$/.exec(line) - if (match === null) malformed() - const name = match![1].toLowerCase() - if (headers.has(name) || (name !== 'content-disposition' && name !== 'content-type')) - malformed() - headers.set(name, match![2].trim()) - } - const disposition = - /^form-data;\s*name="([^"\r\n]{1,128})"(?:;\s*filename="[^"\r\n]{0,256}")?$/i.exec( - headers.get('content-disposition') ?? '' - ) - if (disposition === null) malformed() - const name = disposition![1] - if (seen.has(name)) invalidPaymentProfile = true - seen.add(name) - const partType = headers.get('content-type') - if (partType !== undefined) paymentPayloadContentType(partType) - const start = position + headerLength + 4 - let end = source.indexOf(delimiter, start) - while (end !== -1) { - const suffix = source - .subarray(end + delimiter.length, end + delimiter.length + 2) - .toString('ascii') - if (suffix === '\r\n' || suffix === '--') break - end = source.indexOf(delimiter, end + delimiter.length) - } - if (end === -1) malformed() - if (name === 'x-bsv-payment') { - if (partType === undefined || !/^application\/json(?:;\s*charset=utf-8)?$/i.test(partType)) - malformed() - if (end - start > maxPaymentBytes) - throw new PaymentTransportError( - 'ERR_PAYMENT_SIZE', - 'Multipart payment JSON exceeds its limit.' - ) - paymentJSON = toUTF8Strict(source.subarray(start, end)) - } else if (name === 'body') { - if (partType === undefined) invalidPaymentProfile = true - // Own the inner bytes: downstream views must not retain or expose the outer payment part. - body = new Uint8Array(source.subarray(start, end)) - bodyType = partType - } else invalidPaymentProfile = true - position = end + delimiter.length - if (source.subarray(position, position + 2).toString('ascii') === '--') { - position += 2 - if ( - source.subarray(position).toString('ascii') !== '' && - source.subarray(position).toString('ascii') !== '\r\n' - ) - malformed() - break - } - position += 2 + const part = readPart(source, delimiter, position) + if (seen.has(part.name)) result.invalid = true + seen.add(part.name) + collectPart(result, part, maxPaymentBytes) + if (part.final) break + position = part.next } - if (paymentJSON === undefined) throw new MissingMultipartPayment() - if (invalidPaymentProfile || parts > 2) malformed() - return { paymentJSON: paymentJSON!, body, contentType: bodyType } + if (result.paymentJSON === undefined) throw new MissingMultipartPayment() + if (result.invalid || parts > 2) malformed() + return { paymentJSON: result.paymentJSON, body: result.body, contentType: result.contentType } } diff --git a/packages/middleware/payment-express-middleware/test/brc118-browser.mjs b/packages/middleware/payment-express-middleware/test/brc118-browser.mjs index f2c7c5ee1..fa0b41cc2 100644 --- a/packages/middleware/payment-express-middleware/test/brc118-browser.mjs +++ b/packages/middleware/payment-express-middleware/test/brc118-browser.mjs @@ -111,13 +111,12 @@ try { const pageOrigin = await listen( createServer((req, res) => { res.setHeader('Content-Type', req.url?.endsWith('.js') ? 'text/javascript' : 'text/html') - res.end( - req.url === '/legacy.js' - ? legacyBundle?.outputFiles[0].contents - : req.url === '/client.js' - ? bundle.outputFiles[0].contents - : 'BRC-118 cross-origin acceptance' - ) + if (req.url === '/legacy.js') res.end(legacyBundle?.outputFiles[0].contents) + else if (req.url === '/client.js') res.end(bundle.outputFiles[0].contents) + else + res.end( + 'BRC-118 cross-origin acceptance' + ) }) ) browser = await puppeteer.launch({ executablePath, headless: true, args: ['--no-sandbox'] }) diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index e44cf962c..4e6a88fe9 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -755,42 +755,8 @@ export class AuthFetch { config: SimplifiedFetchRequestOptions, originalResponse: Response ): Promise { - const paymentVersion = originalResponse.headers.get('x-bsv-payment-version') - if (!paymentVersion || paymentVersion !== PAYMENT_VERSION) { - throw new Error( - `Unsupported x-bsv-payment-version response header. Client version: ${PAYMENT_VERSION}, Server version: ${paymentVersion}` - ) - } - - const satoshisRequiredHeader = originalResponse.headers.get('x-bsv-payment-satoshis-required') - if (!satoshisRequiredHeader) { - throw new Error('Missing x-bsv-payment-satoshis-required response header.') - } - if (!/^[1-9]\d*$/.test(satoshisRequiredHeader)) { - throw new Error('Invalid x-bsv-payment-satoshis-required response header value.') - } - const satoshisRequired = Number(satoshisRequiredHeader) - if (!Number.isSafeInteger(satoshisRequired)) { - throw new Error('Invalid x-bsv-payment-satoshis-required response header value.') - } - - const serverIdentityKey = originalResponse.headers.get('x-bsv-auth-identity-key') - if (typeof serverIdentityKey !== 'string') { - throw new TypeError('Missing x-bsv-auth-identity-key response header.') - } - - const derivationPrefix = originalResponse.headers.get('x-bsv-payment-derivation-prefix') - if (typeof derivationPrefix !== 'string' || derivationPrefix.length < 1) { - throw new Error('Missing x-bsv-payment-derivation-prefix response header.') - } - - const knownTxids = parseKnownTxidsHeader(originalResponse.headers.get(KNOWN_TXIDS_HEADER)) - const transports = paymentTransports(originalResponse.headers.get('x-bsv-payment-transports')) - if (transports.size === 0) - throw new PaymentTransportError( - 'ERR_PAYMENT_TRANSPORT', - 'The server advertised no supported payment transport.' - ) + const { satoshisRequired, serverIdentityKey, derivationPrefix, knownTxids, transports } = + paymentRequirements(originalResponse) let paymentContext = config.paymentContext if (paymentContext == null) { @@ -827,68 +793,13 @@ export class AuthFetch { ) } - // Contexts returned by the released API may already represent a spend. - // Preserve them without another wallet mutation, but validate their real wire bytes. - if (paymentContext.preparedRequest === undefined) { - const beef = Beef.fromBinaryStrict(UtilsToArray(paymentContext.transactionBase64, 'base64')) - if (beef.atomicTxid == null) - throw new PaymentTransportError( - 'ERR_PAYMENT_TRANSPORT', - 'Existing payment context requires Atomic BEEF.' - ) - paymentContext.txid = beef.atomicTxid - paymentContext.state = 'uncertain' - paymentContext.originalRequest = { - method: config.method ?? 'GET', - headers: { ...config.headers }, - body: - config.body == null - ? undefined - : new Uint8Array(await this.normalizeBodyToNumberArray(config.body)) - } - paymentContext.preparedRequest = preparePaymentTransport( - JSON.stringify({ - derivationPrefix: paymentContext.derivationPrefix, - derivationSuffix: paymentContext.derivationSuffix, - transaction: paymentContext.transactionBase64 - }), - paymentContext.originalRequest, - transports, - resolvePaymentTransportLimits(config.paymentTransport) - ) - } - if (!transports.has(paymentContext.preparedRequest.transport)) { - try { - paymentContext.preparedRequest = preparePaymentTransport( - JSON.stringify({ - derivationPrefix: paymentContext.derivationPrefix, - derivationSuffix: paymentContext.derivationSuffix, - transaction: paymentContext.transactionBase64 - }), - paymentContext.originalRequest, - transports, - resolvePaymentTransportLimits(config.paymentTransport) - ) - } catch { - throw new PaymentTransportError( - 'ERR_PAYMENT_TRANSPORT', - 'The authenticated server no longer supports a deliverable transport for this existing payment.', - { txid: paymentContext.txid, state: paymentContext.state } - ) - } - } + await this.prepareExistingPaymentRequest(paymentContext, config, transports) if (config.signal?.aborted === true) { - let aborted: boolean | undefined - if (paymentContext.state === 'prepared') { - try { - aborted = - (await this.wallet.abortAction({ reference: paymentContext.txid }, this.originator)) - .aborted === true - } catch { - aborted = false - } - } + const aborted = + paymentContext.state === 'prepared' + ? await this.abortPreparedPayment(paymentContext.txid) + : undefined throw new PaymentTransportError('ERR_PAYMENT_CANCELLED', 'Paid request cancelled.', { txid: paymentContext.txid, state: paymentContext.state, @@ -925,6 +836,24 @@ export class AuthFetch { attemptDetails ) + return this.sendPaidRequest( + url, + nextConfig, + originalResponse, + paymentContext, + attemptDetails, + attemptNumber + ) + } + + private async sendPaidRequest( + url: string, + nextConfig: SimplifiedFetchRequestOptions, + originalResponse: Response, + paymentContext: PaymentRetryContext, + attemptDetails: Record, + attemptNumber: number + ): Promise { try { const response = await this.fetch(url, nextConfig) if (response.status === 413 || response.status === 431) { @@ -974,6 +903,63 @@ export class AuthFetch { } } + private async prepareExistingPaymentRequest( + paymentContext: PaymentRetryContext, + config: SimplifiedFetchRequestOptions, + transports: ReadonlySet + ): Promise { + // Contexts returned by the released API may already represent a spend. + // Preserve them without another wallet mutation, but validate their real wire bytes. + if (paymentContext.preparedRequest === undefined) { + const beef = Beef.fromBinaryStrict(UtilsToArray(paymentContext.transactionBase64, 'base64')) + if (beef.atomicTxid == null) + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Existing payment context requires Atomic BEEF.' + ) + paymentContext.txid = beef.atomicTxid + paymentContext.state = 'uncertain' + paymentContext.originalRequest = { + method: config.method ?? 'GET', + headers: { ...config.headers }, + body: + config.body == null + ? undefined + : new Uint8Array(await this.normalizeBodyToNumberArray(config.body)) + } + paymentContext.preparedRequest = preparePaymentTransport( + JSON.stringify({ + derivationPrefix: paymentContext.derivationPrefix, + derivationSuffix: paymentContext.derivationSuffix, + transaction: paymentContext.transactionBase64 + }), + paymentContext.originalRequest, + transports, + resolvePaymentTransportLimits(config.paymentTransport) + ) + } + if (!transports.has(paymentContext.preparedRequest.transport)) { + try { + paymentContext.preparedRequest = preparePaymentTransport( + JSON.stringify({ + derivationPrefix: paymentContext.derivationPrefix, + derivationSuffix: paymentContext.derivationSuffix, + transaction: paymentContext.transactionBase64 + }), + paymentContext.originalRequest, + transports, + resolvePaymentTransportLimits(config.paymentTransport) + ) + } catch { + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'The authenticated server no longer supports a deliverable transport for this existing payment.', + { txid: paymentContext.txid, state: paymentContext.state } + ) + } + } + } + private isPaymentContextCompatible( context: PaymentRetryContext, satoshisRequired: number, @@ -1097,21 +1083,7 @@ export class AuthFetch { 'BRC-105 payment transaction', MAX_PAYMENT_TRANSACTION_BYTES ) - const beef = Beef.fromBinaryStrict(transaction) - const atomicTxid = beef.atomicTxid - const output = atomicTxid == null ? undefined : beef.findTxid(atomicTxid)?.tx?.outputs[0] - if ( - atomicTxid == null || - (txid != null && txid !== atomicTxid) || - output?.satoshis !== satoshisRequired || - output.lockingScript.toHex() !== lockingScript - ) { - throw new PaymentTransportError( - 'ERR_PAYMENT_TRANSPORT', - 'The prepared payment does not match its authorized recipient and amount.' - ) - } - txid = atomicTxid + txid = preparedPaymentTxid(transaction, txid, satoshisRequired, lockingScript) const transactionBase64 = toBase64(transaction) const preparedRequest = preparePaymentTransport( JSON.stringify({ derivationPrefix, derivationSuffix, transaction: transactionBase64 }), @@ -1150,15 +1122,7 @@ export class AuthFetch { preparedRequest } } catch (error) { - let aborted = false - reference = txid ?? reference - if (reference != null) { - try { - aborted = (await this.wallet.abortAction({ reference }, this.originator)).aborted === true - } catch { - /* retain recovery context */ - } - } + const aborted = await this.abortPreparedPayment(txid ?? reference) throw new PaymentTransportError( error instanceof PaymentTransportError ? error.code : 'ERR_PAYMENT_TRANSPORT', 'Payment preparation could not produce a deliverable request; no broadcast was requested.', @@ -1167,6 +1131,15 @@ export class AuthFetch { } } + private async abortPreparedPayment(reference: string | undefined): Promise { + if (reference == null) return false + try { + return (await this.wallet.abortAction({ reference }, this.originator)).aborted === true + } catch { + return false + } + } + private async submitPreparedPayment(context: PaymentRetryContext): Promise { // Once submission starts its result may be uncertain. Never abort or create a replacement automatically. context.state = 'uncertain' @@ -1474,16 +1447,7 @@ export class AuthFetch { // 5. FormData if (typeof FormData !== 'undefined' && body instanceof FormData) { - const entries: [string, string][] = [] - body.forEach((value, key) => { - if (typeof value !== 'string') - throw new PaymentTransportError( - 'ERR_PAYMENT_TRANSPORT', - 'Serialize file-bearing FormData to owned bytes with its Content-Type before authenticated fetch.' - ) - entries.push([key, value]) - }) - return UtilsToArray(new URLSearchParams(entries).toString(), 'utf8') + return normalizeFormData(body) } // 6. URLSearchParams @@ -1504,6 +1468,83 @@ export class AuthFetch { } } +function paymentRequirements(originalResponse: Response) { + const paymentVersion = originalResponse.headers.get('x-bsv-payment-version') + if (!paymentVersion || paymentVersion !== PAYMENT_VERSION) { + throw new Error( + `Unsupported x-bsv-payment-version response header. Client version: ${PAYMENT_VERSION}, Server version: ${paymentVersion}` + ) + } + + const satoshisRequiredHeader = originalResponse.headers.get('x-bsv-payment-satoshis-required') + if (!satoshisRequiredHeader) { + throw new Error('Missing x-bsv-payment-satoshis-required response header.') + } + if (!/^[1-9]\d*$/.test(satoshisRequiredHeader)) { + throw new Error('Invalid x-bsv-payment-satoshis-required response header value.') + } + const satoshisRequired = Number(satoshisRequiredHeader) + if (!Number.isSafeInteger(satoshisRequired)) { + throw new Error('Invalid x-bsv-payment-satoshis-required response header value.') + } + + const serverIdentityKey = originalResponse.headers.get('x-bsv-auth-identity-key') + if (typeof serverIdentityKey !== 'string') { + throw new TypeError('Missing x-bsv-auth-identity-key response header.') + } + + const derivationPrefix = originalResponse.headers.get('x-bsv-payment-derivation-prefix') + if (typeof derivationPrefix !== 'string' || derivationPrefix.length < 1) { + throw new Error('Missing x-bsv-payment-derivation-prefix response header.') + } + + const knownTxids = parseKnownTxidsHeader(originalResponse.headers.get(KNOWN_TXIDS_HEADER)) + const transports = paymentTransports(originalResponse.headers.get('x-bsv-payment-transports')) + if (transports.size === 0) + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'The server advertised no supported payment transport.' + ) + + return { satoshisRequired, serverIdentityKey, derivationPrefix, knownTxids, transports } +} + +function preparedPaymentTxid( + transaction: number[], + reportedTxid: string | undefined, + satoshisRequired: number, + lockingScript: string +): string { + const beef = Beef.fromBinaryStrict(transaction) + const atomicTxid = beef.atomicTxid + const output = atomicTxid == null ? undefined : beef.findTxid(atomicTxid)?.tx?.outputs[0] + if ( + atomicTxid == null || + (reportedTxid != null && reportedTxid !== atomicTxid) || + output?.satoshis !== satoshisRequired || + output.lockingScript.toHex() !== lockingScript + ) { + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'The prepared payment does not match its authorized recipient and amount.' + ) + } + return atomicTxid +} + +function normalizeFormData(body: FormData): number[] { + const entries: [string, string][] = [] + body.forEach((value, key) => { + if (typeof value !== 'string') + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Serialize file-bearing FormData to owned bytes with its Content-Type before authenticated fetch.' + ) + entries.push([key, value]) + }) + return UtilsToArray(new URLSearchParams(entries).toString(), 'utf8') +} + class StrictResponseReader { private readonly reader: Reader diff --git a/packages/sdk/src/auth/utils/paymentTransport.ts b/packages/sdk/src/auth/utils/paymentTransport.ts index 5f7897b97..2b88be669 100644 --- a/packages/sdk/src/auth/utils/paymentTransport.ts +++ b/packages/sdk/src/auth/utils/paymentTransport.ts @@ -92,7 +92,7 @@ export function isMultipartPaymentType(value: string): boolean { export function paymentBoundary(contentType: string): string { // One unambiguous boundary parameter; quoted MIME token boundaries are accepted. const match = - /^multipart\/form-data\s*;\s*boundary=(?:([A-Za-z0-9'()+_,./:=?-]{1,70})|"([A-Za-z0-9'()+_,./:=?-]{1,70})")\s*$/i.exec( + /^multipart\/form-data\s*;\s*boundary=(?:([a-z0-9'()+_,./:=?-]{1,70})|"([a-z0-9'()+_,./:=?-]{1,70})")\s*$/i.exec( contentType ) if (match == null) @@ -105,7 +105,7 @@ export function paymentPayloadContentType(value: string): string { value.length === 0 || value.length > 1024 || /[^\x20-\x7e]/.test(value) || - !/^[!#$%&'*+.^_`|~\w-]+\/[!#$%&'*+.^_`|~\w-]+(?:\s*;.*)?$/.test(value) + !/^[!#$%&'*+.^`|~\w-]+\/[!#$%&'*+.^`|~\w-]+(?:\s*;.*)?$/.test(value) ) { throw new PaymentTransportError( 'ERR_PAYMENT_TRANSPORT', @@ -138,6 +138,17 @@ export interface MultipartPaymentBody { body: Uint8Array } +function paymentRequestHeaders(originalHeaders: Record): Record { + const headers: Record = Object.create(null) + for (const [name, value] of Object.entries(originalHeaders)) { + const lower = name.toLowerCase() + if (Object.hasOwn(headers, lower)) + throw new PaymentTransportError('ERR_PAYMENT_TRANSPORT', 'Duplicate request header.') + if (lower !== 'x-bsv-payment') headers[lower] = value + } + return headers +} + export function preparePaymentTransport( paymentJSON: string, original: { method: string; headers: Record; body?: Uint8Array }, @@ -150,13 +161,7 @@ export function preparePaymentTransport( 'ERR_PAYMENT_SIZE', 'Payment JSON exceeds its configured limit.' ) - const headers: Record = Object.create(null) - for (const [name, value] of Object.entries(original.headers)) { - const lower = name.toLowerCase() - if (Object.hasOwn(headers, lower)) - throw new PaymentTransportError('ERR_PAYMENT_TRANSPORT', 'Duplicate request header.') - if (lower !== 'x-bsv-payment') headers[lower] = value - } + const headers = paymentRequestHeaders(original.headers) const originalType = headers['content-type'] let body = original.body let transport: 'header' | 'multipart' = 'header' diff --git a/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs b/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs index ed4d39cc1..d27f16e32 100644 --- a/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs +++ b/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs @@ -51,8 +51,8 @@ try { page.on('console', message => process.stdout.write(`${message.text()}\n`)) page.on('pageerror', error => process.stderr.write(`${error}\n`)) await page.goto(`http://127.0.0.1:${server.address().port}`) - await page.waitForFunction('globalThis.syncBenchmark !== undefined', { timeout: 30000 }) - const reports = await page.evaluate(async () => await globalThis.syncBenchmark) + await page.waitForFunction('typeof globalThis.syncBenchmark === "function"', { timeout: 30000 }) + const reports = await page.evaluate(async () => await globalThis.syncBenchmark()) process.stdout.write(`${JSON.stringify({ nativeSync: reports }, null, 2)}\n`) } finally { if (browser !== undefined) await browser.close() diff --git a/packages/wallet/wallet-toolbox/client/test/sync-browser.ts b/packages/wallet/wallet-toolbox/client/test/sync-browser.ts index debef8604..e7ed53a18 100644 --- a/packages/wallet/wallet-toolbox/client/test/sync-browser.ts +++ b/packages/wallet/wallet-toolbox/client/test/sync-browser.ts @@ -184,4 +184,4 @@ async function benchmark() { } } -Object.assign(globalThis, { syncBenchmark: benchmark() }) +Object.assign(globalThis, { syncBenchmark: benchmark }) diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index c9d4ddbf9..732b57572 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -65,7 +65,7 @@ class ManagedStorage { isAvailable: boolean isStorageProvider: boolean settings?: TableSettings - access?: sdk.StorageCapabilities['storageAccess'] + access: sdk.StorageCapabilities['storageAccess'] user?: TableUser constructor(public storage: sdk.WalletStorageProvider) { diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts index 59d804705..5fc846dbf 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts @@ -30,7 +30,7 @@ export class StorageAccessQueue { if (background >= 0 && (this.foregroundGrants >= 8 || Date.now() - this.waiters[background].queuedAt >= 1000)) return background const foreground = this.waiters.findIndex(waiter => waiter.priority === 'foreground') - return foreground >= 0 ? foreground : 0 + return Math.max(foreground, 0) } private pump(): void { diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts index a33b16827..ceae71b52 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts @@ -26,7 +26,7 @@ function marginalCost( ) const slope = Math.max(0, covariance / variance) const fixedMs = Math.max(0, averageMs - slope * averageRecords) - const latest = samples[samples.length - 1] + const latest = samples.at(-1)! // React to a newly expensive page immediately, even when the rolling fit // still contains cheap pages. Never subtract more than its observed cost. return { perRecordMs: Math.max(slope, (latest[phase] - fixedMs) / latest.records), fixedMs } diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.ts index ba6886281..26218f276 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncFailure.ts @@ -1,7 +1,6 @@ import { WalletErrorFromJson } from '../../sdk/WalletErrorFromJson' import { WERR_INVALID_OPERATION, WERR_NETWORK_CHAIN } from '../../sdk/WERR_errors' -import type { RequestSyncChunkArgs } from '../../sdk/WalletStorage.interfaces' -import type { ProcessSyncChunkResult } from '../../sdk/WalletStorage.interfaces' +import type { RequestSyncChunkArgs, ProcessSyncChunkResult } from '../../sdk/WalletStorage.interfaces' import type { TableSettings } from '../schema/tables' const supportedChains = new Set(['main', 'test', 'stn', 'ttn', 'tstn', 'mock']) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts index d3ac3c3dd..8e131c252 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts @@ -67,6 +67,52 @@ function requestCheckpoint(args: RequestSyncChunkArgs): SyncCheckpoint { return validateSyncCheckpoint({ syncStateId: args.syncStateId ?? 0, since: args.since, offsets: args.offsets }) } +type ProgressNotifier = (state: SyncSessionProgress['state'], timing?: Partial) => void + +async function readAndPreparePage( + session: PullSession, + pageArgs: RequestSyncChunkArgs, + cancelled: () => boolean, + notify: ProgressNotifier +): Promise< + { chunk: SyncChunk; apply: () => Promise; readMs: number; prepareMs: number } | undefined +> { + notify('reading') + if (cancelled()) return undefined + const readAt = Date.now() + const chunk = await session.reader.getSyncChunk(pageArgs) + const readMs = Date.now() - readAt + if (cancelled()) return undefined + if ( + chunk.fromStorageIdentityKey !== pageArgs.fromStorageIdentityKey || + chunk.toStorageIdentityKey !== pageArgs.toStorageIdentityKey || + chunk.userIdentityKey !== pageArgs.identityKey + ) { + throw new WERR_INVALID_PARAMETER('chunk', 'bound to this sync source, destination and wallet identity') + } + if (chunk.user != null) chunk.user.activeStorage = session.activeStorage + notify('preparing', { readMs }) + if (cancelled()) return undefined + const prepareAt = Date.now() + const apply = + session.prepare == null + ? async () => await session.writer.processSyncChunk(pageArgs, chunk) + : await session.prepare(pageArgs, chunk) + const prepareMs = Date.now() - prepareAt + if (cancelled()) return undefined + return { chunk, apply, readMs, prepareMs } +} + +async function committedCheckpoint( + session: PullSession, + args: RequestSyncChunkArgs, + reply: ProcessSyncChunkResult +): Promise { + if (reply.nextCheckpoint == null) return requestCheckpoint(await session.loadRequest()) + const expected = reply.done ? { syncStateId: args.syncStateId } : args + return validateSyncCheckpoint(reply.nextCheckpoint, expected) +} + /** One page in flight; resume always starts with the destination's durable checkpoint. */ export async function runPullSession(session: PullSession, options: SyncSessionOptions): Promise { const maxItems = boundedOption(options.maxItems, 'maxItems', 1000) @@ -104,29 +150,9 @@ export async function runPullSession(session: PullSession, options: SyncSessionO includeNextCheckpoint: true, requireMatchingCheckpoint: session.atomicCheckpoint } - notify('reading') - if (cancelled()) return result - const readAt = Date.now() - const chunk = await session.reader.getSyncChunk(pageArgs) - const readMs = Date.now() - readAt - if (cancelled()) return result - if ( - chunk.fromStorageIdentityKey !== pageArgs.fromStorageIdentityKey || - chunk.toStorageIdentityKey !== pageArgs.toStorageIdentityKey || - chunk.userIdentityKey !== pageArgs.identityKey - ) { - throw new WERR_INVALID_PARAMETER('chunk', 'bound to this sync source, destination and wallet identity') - } - if (chunk.user != null) chunk.user.activeStorage = session.activeStorage - notify('preparing', { readMs }) - if (cancelled()) return result - const prepareAt = Date.now() - const apply = - session.prepare == null - ? async () => await session.writer.processSyncChunk(pageArgs, chunk) - : await session.prepare(pageArgs, chunk) - const prepareMs = Date.now() - prepareAt - if (cancelled()) return result + const prepared = await readAndPreparePage(session, pageArgs, cancelled, notify) + if (prepared == null) return result + const { chunk, apply, readMs, prepareMs } = prepared notify('committing', { readMs }) const queuedAt = Date.now() let commitAt = queuedAt @@ -136,10 +162,7 @@ export async function runPullSession(session: PullSession, options: SyncSessionO commitAt = Date.now() const reply = await apply() throwSyncResultError(reply) - const checkpoint = - reply.nextCheckpoint == null - ? requestCheckpoint(await session.loadRequest()) - : validateSyncCheckpoint(reply.nextCheckpoint, reply.done ? { syncStateId: args.syncStateId } : args) + const checkpoint = await committedCheckpoint(session, args, reply) return { reply, checkpoint } }) if (committed == null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73583f5ba..b3023aa49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -974,6 +974,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + express-rate-limit: + specifier: 8.6.1 + version: 8.6.1(express@5.2.1) fast-check: specifier: ^4.9.0 version: 4.9.0 From ca183ece610b2afd8c1ea619591afd8c7fded9ec Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 06:04:56 -0700 Subject: [PATCH 007/127] refactor(payments): separate settlement and bound the raw byte span --- .../src/__tests/MultipartPayment.test.ts | 6 + .../payment-express-middleware/src/index.ts | 145 ++++++++++-------- .../src/multipartPayment.ts | 2 +- packages/sdk/src/auth/clients/AuthFetch.ts | 84 +++++----- 4 files changed, 124 insertions(+), 113 deletions(-) diff --git a/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts b/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts index 0254ab035..c385823ed 100644 --- a/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts +++ b/packages/middleware/payment-express-middleware/src/__tests/MultipartPayment.test.ts @@ -131,6 +131,12 @@ describe('authenticated BRC-118 extraction', () => { expect(() => parse(bytes)).toThrow('Malformed multipart payment') }) + it('measures the raw byte span independently of a shadowed array length', () => { + const bytes = new Uint8Array(wire(part('x-bsv-payment', payment))) + Object.defineProperty(bytes, 'length', { value: 0 }) + expect(() => parseMultipartPayment(bytes, type, bytes.byteLength - 1, 1000)).toThrow('limit') + }) + it('bounds body, payment, and part headers', () => { const bytes = wire(part('x-bsv-payment', payment)) expect(() => parseMultipartPayment(bytes, type, bytes.length - 1, 1000)).toThrow('limit') diff --git a/packages/middleware/payment-express-middleware/src/index.ts b/packages/middleware/payment-express-middleware/src/index.ts index 0b423a8ea..3794d60d2 100644 --- a/packages/middleware/payment-express-middleware/src/index.ts +++ b/packages/middleware/payment-express-middleware/src/index.ts @@ -3,7 +3,7 @@ import { decodePaymentPayload } from '@bsv/sdk/auth/utils/decodePaymentPayload' import { parseMultipartPayment, MissingMultipartPayment } from './multipartPayment.js' import { toArray, toBase64 } from '@bsv/sdk/primitives/utils' import { Beef, createNonce, PublicKey, verifyNonce, type AtomicBEEF } from '@bsv/sdk' -import type { RequestHandler, Response } from 'express' +import type { NextFunction, RequestHandler, Response } from 'express' import type { BSVPayment, PaymentMiddlewareOptions, @@ -280,6 +280,81 @@ function extractPaymentInput( } } +async function settlePayment( + paymentRequest: PaymentRequest, + res: Response, + next: NextFunction, + parsed: ParsedPayment, + identityKey: string, + options: Required> & + Pick +): Promise { + const { wallet, replayStore, logger } = options + try { + const result: unknown = await wallet.internalizeAction({ + tx: parsed.transaction, + outputs: [ + { + paymentRemittance: { + derivationPrefix: parsed.payment.derivationPrefix, + derivationSuffix: parsed.payment.derivationSuffix, + senderIdentityKey: identityKey + }, + outputIndex: 0, + protocol: 'wallet payment' + } + ], + description: 'Payment for request' + }) + + if (!isNewlyAcceptedInternalization(result)) { + sendError(res, 409, 'ERR_PAYMENT_REPLAYED', 'This payment was not newly accepted.') + return + } + + // The wallet is the authority that validates the remittance and records + // whether it was newly accepted. Claim only after that validation so an + // attacker cannot poison a transaction ID by pairing a public BEEF with + // invalid derivation material. A buggy wallet that accepts a duplicate is + // still contained by the independent atomic replay store. + let claimed: boolean + try { + const claimResult: unknown = await replayStore.claim(parsed.transactionId) + if (typeof claimResult !== 'boolean') { + throw new TypeError('The replay store returned an invalid claim result.') + } + claimed = claimResult + } catch (error) { + emitLog(logger, 'error', 'Payment replay claim failed.', safeErrorContext(error)) + sendError( + res, + 503, + 'ERR_PAYMENT_UNAVAILABLE', + 'Payment processing is temporarily unavailable.' + ) + return + } + if (!claimed) { + sendError(res, 409, 'ERR_PAYMENT_REPLAYED', 'This payment was already used.') + return + } + + paymentRequest.payment = { + satoshisPaid: parsed.satoshis, + accepted: true, + tx: toBase64(parsed.transaction), + txid: parsed.transactionId + } + res.set({ + 'x-bsv-payment-satoshis-paid': String(parsed.satoshis) + }) + next() + } catch (error) { + emitLog(logger, 'warn', 'Payment internalization failed.', safeErrorContext(error)) + sendError(res, 400, 'ERR_PAYMENT_FAILED', 'The payment could not be accepted.') + } +} + /** * Creates middleware that enforces a BRC-29 wallet payment after BRC-103 auth. */ @@ -421,68 +496,10 @@ export function createPaymentMiddleware(options: PaymentMiddlewareOptions): Requ return } - try { - const result: unknown = await wallet.internalizeAction({ - tx: parsed.transaction, - outputs: [ - { - paymentRemittance: { - derivationPrefix: payment.derivationPrefix, - derivationSuffix: payment.derivationSuffix, - senderIdentityKey: identityKey - }, - outputIndex: 0, - protocol: 'wallet payment' - } - ], - description: 'Payment for request' - }) - - if (!isNewlyAcceptedInternalization(result)) { - sendError(res, 409, 'ERR_PAYMENT_REPLAYED', 'This payment was not newly accepted.') - return - } - - // The wallet is the authority that validates the remittance and records - // whether it was newly accepted. Claim only after that validation so an - // attacker cannot poison a transaction ID by pairing a public BEEF with - // invalid derivation material. A buggy wallet that accepts a duplicate is - // still contained by the independent atomic replay store. - let claimed: boolean - try { - const claimResult: unknown = await replayStore.claim(parsed.transactionId) - if (typeof claimResult !== 'boolean') { - throw new TypeError('The replay store returned an invalid claim result.') - } - claimed = claimResult - } catch (error) { - emitLog(logger, 'error', 'Payment replay claim failed.', safeErrorContext(error)) - sendError( - res, - 503, - 'ERR_PAYMENT_UNAVAILABLE', - 'Payment processing is temporarily unavailable.' - ) - return - } - if (!claimed) { - sendError(res, 409, 'ERR_PAYMENT_REPLAYED', 'This payment was already used.') - return - } - - paymentRequest.payment = { - satoshisPaid: parsed.satoshis, - accepted: true, - tx: toBase64(parsed.transaction), - txid: parsed.transactionId - } - res.set({ - 'x-bsv-payment-satoshis-paid': String(parsed.satoshis) - }) - next() - } catch (error) { - emitLog(logger, 'warn', 'Payment internalization failed.', safeErrorContext(error)) - sendError(res, 400, 'ERR_PAYMENT_FAILED', 'The payment could not be accepted.') - } + await settlePayment(paymentRequest, res, next, parsed, identityKey, { + wallet, + replayStore, + logger + }) } } diff --git a/packages/middleware/payment-express-middleware/src/multipartPayment.ts b/packages/middleware/payment-express-middleware/src/multipartPayment.ts index e20e4da1e..2a63f9aab 100644 --- a/packages/middleware/payment-express-middleware/src/multipartPayment.ts +++ b/packages/middleware/payment-express-middleware/src/multipartPayment.ts @@ -116,7 +116,7 @@ export function parseMultipartPayment( maxPaymentBytes: number ): ParsedMultipartPayment { if (!(bytes instanceof Uint8Array)) malformed() - if (bytes.length > maxBodyBytes) + if (bytes.byteLength > maxBodyBytes) throw new PaymentTransportError('ERR_PAYMENT_SIZE', 'Multipart payment body exceeds its limit.') const boundary = paymentBoundary(contentType) const source = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index 4e6a88fe9..b68af758c 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -910,7 +910,8 @@ export class AuthFetch { ): Promise { // Contexts returned by the released API may already represent a spend. // Preserve them without another wallet mutation, but validate their real wire bytes. - if (paymentContext.preparedRequest === undefined) { + const legacy = paymentContext.preparedRequest === undefined + if (legacy) { const beef = Beef.fromBinaryStrict(UtilsToArray(paymentContext.transactionBase64, 'base64')) if (beef.atomicTxid == null) throw new PaymentTransportError( @@ -927,6 +928,8 @@ export class AuthFetch { ? undefined : new Uint8Array(await this.normalizeBodyToNumberArray(config.body)) } + } else if (transports.has(paymentContext.preparedRequest.transport)) return + try { paymentContext.preparedRequest = preparePaymentTransport( JSON.stringify({ derivationPrefix: paymentContext.derivationPrefix, @@ -937,26 +940,13 @@ export class AuthFetch { transports, resolvePaymentTransportLimits(config.paymentTransport) ) - } - if (!transports.has(paymentContext.preparedRequest.transport)) { - try { - paymentContext.preparedRequest = preparePaymentTransport( - JSON.stringify({ - derivationPrefix: paymentContext.derivationPrefix, - derivationSuffix: paymentContext.derivationSuffix, - transaction: paymentContext.transactionBase64 - }), - paymentContext.originalRequest, - transports, - resolvePaymentTransportLimits(config.paymentTransport) - ) - } catch { - throw new PaymentTransportError( - 'ERR_PAYMENT_TRANSPORT', - 'The authenticated server no longer supports a deliverable transport for this existing payment.', - { txid: paymentContext.txid, state: paymentContext.state } - ) - } + } catch (error) { + if (legacy) throw error + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'The authenticated server no longer supports a deliverable transport for this existing payment.', + { txid: paymentContext.txid, state: paymentContext.state } + ) } } @@ -1069,6 +1059,7 @@ export class AuthFetch { const rawCreated = await this.wallet.createAction(createArgs, this.originator) let txid: string | undefined let reference: string | undefined + let preparationError: unknown try { // A malformed result can still identify a prepared reservation. Read only // a canonical own data field, never an accessor supplied by an adapter. @@ -1122,13 +1113,17 @@ export class AuthFetch { preparedRequest } } catch (error) { - const aborted = await this.abortPreparedPayment(txid ?? reference) - throw new PaymentTransportError( - error instanceof PaymentTransportError ? error.code : 'ERR_PAYMENT_TRANSPORT', - 'Payment preparation could not produce a deliverable request; no broadcast was requested.', - txid == null ? undefined : { txid, state: 'prepared', aborted } - ) + preparationError = error } + // A failed preparation may still own a reservation. Cleanup precedes the refusal. + const aborted = await this.abortPreparedPayment(txid ?? reference) + throw new PaymentTransportError( + preparationError instanceof PaymentTransportError + ? preparationError.code + : 'ERR_PAYMENT_TRANSPORT', + 'Payment preparation could not produce a deliverable request; no broadcast was requested.', + txid == null ? undefined : { txid, state: 'prepared', aborted } + ) } private async abortPreparedPayment(reference: string | undefined): Promise { @@ -1430,13 +1425,10 @@ export class AuthFetch { return UtilsToArray(body, 'utf8') } - // 3. ArrayBuffer / TypedArrays - if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { - const typedArray = - body instanceof ArrayBuffer - ? new Uint8Array(body) - : new Uint8Array(body.buffer, body.byteOffset, body.byteLength) - return Array.from(typedArray) + // 3. ArrayBuffer / TypedArrays: preserve the exact view's byte offset and length. + if (body instanceof ArrayBuffer) body = new Uint8Array(body) + if (ArrayBuffer.isView(body)) { + return Array.from(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)) } // 4. Blob @@ -1447,7 +1439,16 @@ export class AuthFetch { // 5. FormData if (typeof FormData !== 'undefined' && body instanceof FormData) { - return normalizeFormData(body) + const entries: [string, string][] = [] + body.forEach((value, key) => { + if (typeof value !== 'string') + throw new PaymentTransportError( + 'ERR_PAYMENT_TRANSPORT', + 'Serialize file-bearing FormData to owned bytes with its Content-Type before authenticated fetch.' + ) + entries.push([key, value]) + }) + return UtilsToArray(new URLSearchParams(entries).toString(), 'utf8') } // 6. URLSearchParams @@ -1532,19 +1533,6 @@ function preparedPaymentTxid( return atomicTxid } -function normalizeFormData(body: FormData): number[] { - const entries: [string, string][] = [] - body.forEach((value, key) => { - if (typeof value !== 'string') - throw new PaymentTransportError( - 'ERR_PAYMENT_TRANSPORT', - 'Serialize file-bearing FormData to owned bytes with its Content-Type before authenticated fetch.' - ) - entries.push([key, value]) - }) - return UtilsToArray(new URLSearchParams(entries).toString(), 'utf8') -} - class StrictResponseReader { private readonly reader: Reader From 7f72915ae2793f91a1f098af21ab8e77f04c7b51 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 06:23:35 -0700 Subject: [PATCH 008/127] fix(templates): preserve SDK classes in CommonJS consumers --- docs/guides/wallet-sync-reliability.md | 34 ++-- docs/packages/helpers/templates.md | 14 +- docs/packages/overlays/overlay-topics.md | 10 +- docs/reference/package-api-migrations.md | 12 +- docs/reference/stack-facts.md | 4 +- governance/package-release-notes.json | 12 +- governance/repository-health/baselines.json | 4 +- packages/helpers/ts-templates/CHANGELOG.md | 9 + packages/helpers/ts-templates/README.md | 13 ++ packages/helpers/ts-templates/package.json | 4 +- .../helpers/ts-templates/src/MandalaAdmin.ts | 7 +- .../helpers/ts-templates/src/MandalaToken.ts | 6 +- .../helpers/ts-templates/src/MultiPushDrop.ts | 8 +- packages/helpers/ts-templates/src/OpReturn.ts | 3 +- packages/helpers/ts-templates/src/P2MSKH.ts | 8 +- .../helpers/ts-templates/src/R1K1Wallet.ts | 8 +- .../ts-templates/src/signing-context.ts | 2 +- packages/overlays/topics/CHANGELOG.md | 5 + packages/overlays/topics/README.md | 4 + packages/overlays/topics/package.json | 2 +- scripts/check-template-consumers.mjs | 176 ++++++++++++++++++ 21 files changed, 273 insertions(+), 72 deletions(-) create mode 100644 scripts/check-template-consumers.mjs diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 708f9b1fe..fcf03284a 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -188,32 +188,32 @@ Foreground latency starts when its timer callback runs. Event-loop delay is reported separately so synchronous CPU stalls remain visible. The exclusive control has very few completed foreground samples because it holds the queue. -| Backend / mode | Full copy ms | Foreground samples | Foreground p50 / p95 / p99 ms | Event-loop p95 / p99 ms | Pages | -| ------------------------------ | -----------: | -----------------: | ----------------------------- | ----------------------- | ----: | -| Chromium IndexedDB / exclusive | 4143.7 | 1 | 4137.90 / 4137.90 / 4137.90 | 1.30 / 1.90 | 45 | -| Chromium IndexedDB / paged | 4277.2 | 393 | 1.00 / 23.60 / 138.40 | 1.20 / 1.50 | 45 | -| sqlite / exclusive | 2413.1 | 1 | 2377.71 / 2377.71 / 2377.71 | 87.98 / 111.34 | 45 | -| sqlite / paged | 2403.5 | 131 | 0.23 / 0.36 / 0.40 | 92.89 / 111.37 | 45 | -| http / exclusive | 22281.5 | 5 | 0.24 / 22190.60 / 22190.60 | 208.04 / 230.11 | 47 | -| http / paged | 22294.3 | 314 | 0.29 / 0.39 / 0.44 | 209.22 / 231.82 | 47 | - -Native browser peak JS heap was 75.8 MB exclusive and 53.9 MB paged. SQLite -full-copy CPU was 1.91/1.83 seconds and sampled RSS growth 94.9/99.6 MB. -Authenticated HTTP CPU was 22.01/22.03 seconds, RSS growth 323.6/269.1 MB, -and response bodies 7,173,923/7,172,956 bytes. Values are exclusive/paged; +| Backend / mode | Full copy ms | Foreground samples | Foreground p50 / p95 / p99 ms | Event-loop p95 / p99 ms | Pages | +| --- | ---: | ---: | --- | --- | ---: | +| Chromium IndexedDB / exclusive | 4027.1 | 1 | 4021.50 / 4021.50 / 4021.50 | 1.30 / 1.60 | 45 | +| Chromium IndexedDB / paged | 4110.0 | 384 | 0.90 / 25.90 / 132.60 | 1.30 / 1.70 | 45 | +| sqlite / exclusive | 2368.7 | 1 | 2342.43 / 2342.43 / 2342.43 | 85.12 / 106.86 | 45 | +| sqlite / paged | 2373.8 | 133 | 0.17 / 0.32 / 0.40 | 84.86 / 109.29 | 45 | +| http / exclusive | 26184.7 | 5 | 0.25 / 26092.86 / 26092.86 | 250.70 / 271.70 | 47 | +| http / paged | 26394.8 | 319 | 0.24 / 0.35 / 0.40 | 255.93 / 271.98 | 47 | + +Native browser peak JS heap was 79.8 MB exclusive and 56.3 MB paged. SQLite +full-copy CPU was 1.86/1.86 seconds and sampled RSS growth 127.6/60.1 MB. +Authenticated HTTP CPU was 25.93/26.08 seconds, RSS growth 152.1/95.1 MB, +and response bodies 7,173,777/7,172,813 bytes. Values are exclusive/paged; HTTP bytes exclude headers, requests and TLS framing. HTTP and SQLite include source and destination in the same process. Absolute RSS includes the test runner and previously allocated heap; sampled growth is not a total memory cap. -SQLite issued 31,612/31,872 SQL queries and HTTP issued 32,315/32,919, including +SQLite issued 31,612/31,876 SQL queries and HTTP issued 32,241/32,866, including foreground operations. Extra queries reflect completed foreground work; this change does not claim a database-query reduction. Paged maximum commit times -were 143 ms (browser), 104 ms (SQLite) and 92 ms (HTTP). Maximum measured paged -queue wait was 1 ms in all three fixtures. +were 142 ms (browser), 104 ms (SQLite) and 81 ms (HTTP). Maximum measured paged +queue wait was 1 ms in the browser and 0 ms in the local SQL/HTTP fixtures. All modes preserved the full same-timestamp boundary reread: 45 pages locally and 47 over HTTP; a subsequent settled unchanged copy used two pages. The -network fixture remains CPU-heavy, with about 209 ms event-loop p95 despite +network fixture remains CPU-heavy, with about 256 ms event-loop p95 despite short queue waits. Moving crypto/serialization off the main thread is future work; queue responsiveness must not be represented as eliminating that cost. These are reproducible local observations, not cross-device performance promises. diff --git a/docs/packages/helpers/templates.md b/docs/packages/helpers/templates.md index 247737561..ea51850d0 100644 --- a/docs/packages/helpers/templates.md +++ b/docs/packages/helpers/templates.md @@ -3,10 +3,10 @@ id: pkg-templates title: '@bsv/templates' kind: package domain: helpers -version: '1.10.2' +version: '1.10.3' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/templates' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates' @@ -38,6 +38,14 @@ const decodedData = OpReturn.decode(lockingScript) console.log(decodedData) // ['APP', '{"action":"vote"}'] ``` +## CommonJS compatibility + +The 1.10.3 candidate fixes SDK default-import double wrapping in CommonJS. +Both module formats retain the same root/wildcard exports and script encodings. +Packed acceptance constructs and signs scripts, including Mandala, multisig, +MultiPushDrop and R1K1 recovery, with published SDK 2.8.0 and the candidate SDK. +This source candidate is not yet published. + ## What it provides - **OpReturn** — Non-spendable data storage; create and decode OP_RETURN scripts diff --git a/docs/packages/overlays/overlay-topics.md b/docs/packages/overlays/overlay-topics.md index af72234ec..04b903866 100644 --- a/docs/packages/overlays/overlay-topics.md +++ b/docs/packages/overlays/overlay-topics.md @@ -4,9 +4,9 @@ title: '@bsv/overlay-topics' kind: package domain: overlays npm: '@bsv/overlay-topics' -version: '1.8.4' -last_updated: '2026-09-18' -last_verified: '2026-09-18' +version: '1.8.5' +last_updated: '2026-09-23' +last_verified: '2026-09-23' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics' status: stable @@ -23,6 +23,10 @@ tags: ['overlay', 'topics', 'uhrp'] npm install @bsv/overlay-topics ``` +The unpublished 1.8.5 candidate refreshes the packed templates range for its +CommonJS compatibility repair. Topic APIs, schemas and admission behavior are +unchanged; no API migration is required. + ## Quick start ```typescript diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index ecf3d7dbb..0165a45c3 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -45,12 +45,12 @@ and clean-consumer tests remain the executable type authority. | `@bsv/overlay` | `2.6.1` | `2.6.1` | none | [API and usage](../packages/overlays/overlay.md) | Stop writes and take coordinated SQL and lookup-store backups before running every new Knex migration. Preflight and reconcile exact duplicate (txid, outputIndex, topic) output keys and (txid, topic) applied-transaction keys from transaction, topic, and lookup evidence; the uniqueness migration intentionally fails rather than deleting security state. Apply topical uniqueness before the additive spentBy column and verify both before serving writes. Prefer roll-forward: older versions do not know these migration names, so an image-only rollback can fail migration-list validation. To restore an older version, keep writes stopped and either use the new migration source to reverse spentBy and topical uniqueness in reverse order after exporting and reconciling every spent/spentBy association, or restore coordinated pre-migration SQL and lookup-store backups. Configure canonical header resolution and public HTTPS endpoints for production network paths; custom storage, topic, discovery, and transport adapters must satisfy the new validation and resource bounds. External topic indexes are not made globally atomic by SQL submission serialization, so retain idempotent compensation for side effects outside the overlay database. Valid canonical overlay wire shapes remain supported. | | `@bsv/overlay-discovery-services` | `2.2.5` | `2.2.6` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | | `@bsv/overlay-express` | `2.7.2` | `2.7.3` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.8.4` | `1.8.4` | none | [API and usage](../packages/overlays/overlay-topics.md) | Topic and lookup identifiers and valid canonical wire encodings remain unchanged. Audit historical rows for malformed amounts, noncanonical identifiers, incomplete ownership or admin evidence, and ambiguous outpoint linkage before replay or rebuild. Custom state and screening providers must return exact booleans, compare identity keys case-insensitively where documented, conserve exact safe-integer value, and honor bounded query and result contracts. | +| `@bsv/overlay-topics` | `1.8.4` | `1.8.5` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | | `@bsv/paymail` | `2.4.9` | `2.4.9` | none | [API and usage](../packages/messaging/paymail.md) | Valid public APIs and deployed Paymail wire shapes remain supported; malformed, ambiguous, oversized, local-network, wrong-owner, mutation-backed, or request-mismatched input now fails closed. Production origins and capability endpoints must be credential-free public HTTPS, except exact localhost development; custom transports and resolver configuration must use the documented exact runtime types. Receive-route verifySignature defaults to false for legacy compatibility, so unsigned metadata must not authorize a sender. Even when enabled, the legacy P2P signature authenticates only the transaction ID, not recipient, reference, sender-handle context, endpoint, freshness, or replay state. The historical 6745385c3fc0 advertisement is this package's compatibility behavior and is not the upstream signed/timestamped Basic Address Resolution assurance; a complete correction needs a versioned wire migration. Transaction Negotiation v1 is unauthenticated public input. Handlers must validate outputs, references, thread/freshness policy, proofs, callbacks, and durable replay state before financial or authorization effects. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/payment-express-middleware` | `2.1.7` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | | `@bsv/sdk` | `2.8.0` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and non-multipart signing retain their wire format. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. | | `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.2` | none | [API and usage](../packages/helpers/templates.md) | No API migration is required for valid templates. MandalaToken now rejects locking or decoding amounts outside JavaScript's positive safe-integer range; audit any previously accepted non-exact amount scripts before replay. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/templates` | `1.10.2` | `1.10.3` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Upgrade templates to this patch after publication; both CommonJS and ESM consumers retain the same exports and valid script bytes. Existing wallet releases keep their currently qualified pins and compatibility patch until a separately coordinated upgrade. | | `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | | `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | @@ -316,8 +316,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay-topics.md](../packages/overlays/overlay-topics.md) - Source: [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) -- Release note: Hardens topic managers and lookup services with strict bounded query schemas, canonical token amounts, signatures, ownership and outpoint binding, exact screening verdicts, idempotent admission, immutable state, and mutation-safe result limits across Mandala, UMP, UORA, certificates, identity, and related topics. -- Migration: Topic and lookup identifiers and valid canonical wire encodings remain unchanged. Audit historical rows for malformed amounts, noncanonical identifiers, incomplete ownership or admin evidence, and ambiguous outpoint linkage before replay or rebuild. Custom state and screening providers must return exact booleans, compare identity keys case-insensitively where documented, conserve exact safe-integer value, and honor bounded query and result contracts. +- Release note: Refreshes the packed templates dependency range for its CommonJS import interoperability repair. Topic APIs, schemas and admission behavior are unchanged. +- Migration: No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | -------------------------------------- | --------------------- | @@ -457,8 +457,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/templates.md](../packages/helpers/templates.md) - Source: [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) -- Release note: Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. Also consolidates MultiPushDrop script construction without changing its output, makes Mandala token amounts exact positive safe integers, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. -- Migration: No API migration is required for valid templates. MandalaToken now rejects locking or decoding amounts outside JavaScript's positive safe-integer range; audit any previously accepted non-exact amount scripts before replay. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. +- Release note: Fixes CommonJS construction and signing by consuming the SDK named category exports instead of double-wrapped default leaf imports. Adds exact-tarball CJS/ESM script execution checks with published SDK 2.8.0 and the candidate SDK; public APIs and script encodings are unchanged. +- Migration: No API migration. Upgrade templates to this patch after publication; both CommonJS and ESM consumers retain the same exports and valid script bytes. Existing wallet releases keep their currently qualified pins and compatibility patch until a separately coordinated upgrade. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | --------------------------------------- | ------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 7ac5f0b12..f966d51d4 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -44,7 +44,7 @@ authorized release action. | helpers | `@bsv/did-client` | `1.3.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | | helpers | `@bsv/fund-wallet` | `1.5.3` | cli | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | | helpers | `@bsv/simple` | `0.6.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | -| helpers | `@bsv/templates` | `1.10.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | +| helpers | `@bsv/templates` | `1.10.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | | helpers | `@bsv/wallet-helper` | `0.1.9` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | | helpers | `create-bsv-app` | `1.1.2` | cli | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | | messaging | `@bsv/authsocket` | `2.1.8` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | @@ -61,7 +61,7 @@ authorized release action. | overlays | `@bsv/overlay` | `2.6.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay) | | overlays | `@bsv/overlay-discovery-services` | `2.2.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | | overlays | `@bsv/overlay-express` | `2.7.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | -| overlays | `@bsv/overlay-topics` | `1.8.4` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | +| overlays | `@bsv/overlay-topics` | `1.8.5` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | | sdk | `@bsv/sdk` | `2.9.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | | sdk | `@bsv/verifast` | `0.3.6` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | | wallet | `@bsv/btms` | `1.2.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index d7cb1ee38..86c49eb92 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -146,9 +146,9 @@ { "name": "@bsv/overlay-topics", "publishedVersion": "1.8.4", - "releaseType": "none", - "summary": "Hardens topic managers and lookup services with strict bounded query schemas, canonical token amounts, signatures, ownership and outpoint binding, exact screening verdicts, idempotent admission, immutable state, and mutation-safe result limits across Mandala, UMP, UORA, certificates, identity, and related topics.", - "migration": "Topic and lookup identifiers and valid canonical wire encodings remain unchanged. Audit historical rows for malformed amounts, noncanonical identifiers, incomplete ownership or admin evidence, and ambiguous outpoint linkage before replay or rebuild. Custom state and screening providers must return exact booleans, compare identity keys case-insensitively where documented, conserve exact safe-integer value, and honor bounded query and result contracts." + "releaseType": "patch", + "summary": "Refreshes the packed templates dependency range for its CommonJS import interoperability repair. Topic APIs, schemas and admission behavior are unchanged.", + "migration": "No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins." }, { "name": "@bsv/paymail", @@ -181,9 +181,9 @@ { "name": "@bsv/templates", "publishedVersion": "1.10.2", - "releaseType": "none", - "summary": "Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. Also consolidates MultiPushDrop script construction without changing its output, makes Mandala token amounts exact positive safe integers, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice.", - "migration": "No API migration is required for valid templates. MandalaToken now rejects locking or decoding amounts outside JavaScript's positive safe-integer range; audit any previously accepted non-exact amount scripts before replay. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package." + "releaseType": "patch", + "summary": "Fixes CommonJS construction and signing by consuming the SDK named category exports instead of double-wrapped default leaf imports. Adds exact-tarball CJS/ESM script execution checks with published SDK 2.8.0 and the candidate SDK; public APIs and script encodings are unchanged.", + "migration": "No API migration. Upgrade templates to this patch after publication; both CommonJS and ESM consumers retain the same exports and valid script bytes. Existing wallet releases keep their currently qualified pins and compatibility patch until a separately coordinated upgrade." }, { "name": "@bsv/teranode-listener", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 8d83f341d..9e634f466 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -307,7 +307,7 @@ "@bsv/did-client": "1.3.3", "@bsv/fund-wallet": "1.5.3", "@bsv/simple": "0.6.1", - "@bsv/templates": "1.10.2", + "@bsv/templates": "1.10.3", "@bsv/authsocket": "2.1.8", "@bsv/authsocket-client": "2.1.7", "@bsv/message-box-client": "2.6.0", @@ -321,7 +321,7 @@ "@bsv/overlay": "2.6.1", "@bsv/overlay-discovery-services": "2.2.6", "@bsv/overlay-express": "2.7.3", - "@bsv/overlay-topics": "1.8.4", + "@bsv/overlay-topics": "1.8.5", "@bsv/sdk": "2.9.0", "@bsv/verifast": "0.3.6", "@bsv/btms": "1.2.3", diff --git a/packages/helpers/ts-templates/CHANGELOG.md b/packages/helpers/ts-templates/CHANGELOG.md index 27dd64b21..72c22f9b0 100644 --- a/packages/helpers/ts-templates/CHANGELOG.md +++ b/packages/helpers/ts-templates/CHANGELOG.md @@ -10,6 +10,15 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### 1.10.3 candidate — CommonJS interoperability + +- Consume named SDK category exports so CommonJS and ESM consumers construct + and sign scripts with the same classes. Fixes the double-wrapped default + imports reported in #571; public APIs and valid script bytes are unchanged. +- Exercise packed root and wildcard exports, script construction and real + signature execution in clean CommonJS/ESM consumers on SDK 2.8.0 and the + candidate SDK. Existing browser bundle budgets remain in force. + ### 1.10.2 candidate — signing-context and template hardening ### Added diff --git a/packages/helpers/ts-templates/README.md b/packages/helpers/ts-templates/README.md index 49d11128d..6a105f8e4 100644 --- a/packages/helpers/ts-templates/README.md +++ b/packages/helpers/ts-templates/README.md @@ -25,6 +25,19 @@ tx.addOutput({ }) ``` +## Module compatibility + +The unpublished 1.10.3 candidate repairs CommonJS SDK import interop reported +in [stack#571](https://github.com/bsv-blockchain/ts-stack/issues/571). Both +`require('@bsv/templates')` and ESM imports construct and sign the same scripts; +root and `@bsv/templates/MandalaToken.ts` exports retain their public shape. +No API or script-encoding migration is required. + +`pnpm pack:check` runs synthetic signed spends from exact packed artifacts in +clean CJS and ESM consumers with published SDK 2.8.0 and the candidate SDK. +For offline qualification, `TEMPLATES_PUBLISHED_SDK_TARBALL` may name a locally +verified SDK 2.8.0 tarball; the check verifies its installed version. + ## Current Templates | Name | Description | diff --git a/packages/helpers/ts-templates/package.json b/packages/helpers/ts-templates/package.json index 0619de2b6..29fc1ecb0 100644 --- a/packages/helpers/ts-templates/package.json +++ b/packages/helpers/ts-templates/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/templates", - "version": "1.10.2", + "version": "1.10.3", "sideEffects": false, "engines": { "node": ">=22" @@ -47,7 +47,7 @@ "dev": "pnpm build --watch", "format:check": "pnpm --workspace-root exec prettier --check \"packages/helpers/ts-templates/src/**/*.ts\" \"packages/helpers/ts-templates/__tests/*.ts\" \"packages/helpers/ts-templates/*.{js,json,ts}\"", "lint": "oxlint src __tests --deny-warnings", - "pack:check": "node ../../../scripts/check-package-artifact.mjs . --exports ADMIN_PROTOCOL,Bsv21Token,DstasToken,MandalaAdmin,MandalaToken,MultiPushDrop,OpReturn,P2MSKH,R1K1Wallet,StasToken", + "pack:check": "node ../../../scripts/check-package-artifact.mjs . --exports ADMIN_PROTOCOL,Bsv21Token,DstasToken,MandalaAdmin,MandalaToken,MultiPushDrop,OpReturn,P2MSKH,R1K1Wallet,StasToken && node ../../../scripts/check-template-consumers.mjs", "prepublishOnly": "pnpm build", "test": "jest", "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", diff --git a/packages/helpers/ts-templates/src/MandalaAdmin.ts b/packages/helpers/ts-templates/src/MandalaAdmin.ts index e81157209..445bcd01f 100644 --- a/packages/helpers/ts-templates/src/MandalaAdmin.ts +++ b/packages/helpers/ts-templates/src/MandalaAdmin.ts @@ -1,11 +1,8 @@ +import { Signature, TransactionSignature } from '@bsv/sdk/primitives' +import { LockingScript, OP, UnlockingScript } from '@bsv/sdk/script' import { hash160, hash256, sha256 } from '@bsv/sdk/primitives/Hash' -import Signature from '@bsv/sdk/primitives/Signature' -import TransactionSignature from '@bsv/sdk/primitives/TransactionSignature' import { toArray, toHex, toUTF8 } from '@bsv/sdk/primitives/utils' -import LockingScript from '@bsv/sdk/script/LockingScript' -import OP from '@bsv/sdk/script/OP' import type ScriptTemplateUnlock from '@bsv/sdk/script/ScriptTemplateUnlock' -import UnlockingScript from '@bsv/sdk/script/UnlockingScript' import type Transaction from '@bsv/sdk/transaction/Transaction' import type { WalletCounterparty, diff --git a/packages/helpers/ts-templates/src/MandalaToken.ts b/packages/helpers/ts-templates/src/MandalaToken.ts index 662aa6315..492dcbfe0 100644 --- a/packages/helpers/ts-templates/src/MandalaToken.ts +++ b/packages/helpers/ts-templates/src/MandalaToken.ts @@ -1,12 +1,10 @@ +import { TransactionSignature } from '@bsv/sdk/primitives' +import { LockingScript, OP, UnlockingScript } from '@bsv/sdk/script' import { hash160, sha256 } from '@bsv/sdk/primitives/Hash' import type PrivateKey from '@bsv/sdk/primitives/PrivateKey' -import TransactionSignature from '@bsv/sdk/primitives/TransactionSignature' import { toArray } from '@bsv/sdk/primitives/utils' -import LockingScript from '@bsv/sdk/script/LockingScript' -import OP from '@bsv/sdk/script/OP' import type ScriptTemplate from '@bsv/sdk/script/ScriptTemplate' import type ScriptTemplateUnlock from '@bsv/sdk/script/ScriptTemplateUnlock' -import UnlockingScript from '@bsv/sdk/script/UnlockingScript' import type Transaction from '@bsv/sdk/transaction/Transaction' import type { WalletCounterparty, diff --git a/packages/helpers/ts-templates/src/MultiPushDrop.ts b/packages/helpers/ts-templates/src/MultiPushDrop.ts index 7ef8c45ba..b48fa4202 100644 --- a/packages/helpers/ts-templates/src/MultiPushDrop.ts +++ b/packages/helpers/ts-templates/src/MultiPushDrop.ts @@ -1,14 +1,10 @@ +import { PublicKey, Signature, TransactionSignature } from '@bsv/sdk/primitives' +import { LockingScript, OP, UnlockingScript } from '@bsv/sdk/script' import { hash256 } from '@bsv/sdk/primitives/Hash' -import PublicKey from '@bsv/sdk/primitives/PublicKey' -import Signature from '@bsv/sdk/primitives/Signature' -import TransactionSignature from '@bsv/sdk/primitives/TransactionSignature' import { toArray, toHex } from '@bsv/sdk/primitives/utils' -import LockingScript from '@bsv/sdk/script/LockingScript' -import OP from '@bsv/sdk/script/OP' import type ScriptChunk from '@bsv/sdk/script/ScriptChunk' import type ScriptTemplate from '@bsv/sdk/script/ScriptTemplate' import type ScriptTemplateUnlock from '@bsv/sdk/script/ScriptTemplateUnlock' -import UnlockingScript from '@bsv/sdk/script/UnlockingScript' import type Transaction from '@bsv/sdk/transaction/Transaction' import type { PubKeyHex, diff --git a/packages/helpers/ts-templates/src/OpReturn.ts b/packages/helpers/ts-templates/src/OpReturn.ts index dfef927d1..4516121e7 100644 --- a/packages/helpers/ts-templates/src/OpReturn.ts +++ b/packages/helpers/ts-templates/src/OpReturn.ts @@ -1,6 +1,5 @@ +import { LockingScript, OP } from '@bsv/sdk/script' import { toArray, toUTF8 } from '@bsv/sdk/primitives/utils' -import LockingScript from '@bsv/sdk/script/LockingScript' -import OP from '@bsv/sdk/script/OP' import type Script from '@bsv/sdk/script/Script' import type ScriptTemplate from '@bsv/sdk/script/ScriptTemplate' import type UnlockingScript from '@bsv/sdk/script/UnlockingScript' diff --git a/packages/helpers/ts-templates/src/P2MSKH.ts b/packages/helpers/ts-templates/src/P2MSKH.ts index 97cdf1bed..50455bdee 100644 --- a/packages/helpers/ts-templates/src/P2MSKH.ts +++ b/packages/helpers/ts-templates/src/P2MSKH.ts @@ -1,13 +1,9 @@ +import { PublicKey, Signature, TransactionSignature } from '@bsv/sdk/primitives' +import { LockingScript, OP, UnlockingScript } from '@bsv/sdk/script' import { hash160, hash256 } from '@bsv/sdk/primitives/Hash' -import PublicKey from '@bsv/sdk/primitives/PublicKey' -import Signature from '@bsv/sdk/primitives/Signature' -import TransactionSignature from '@bsv/sdk/primitives/TransactionSignature' import { Reader, Writer, fromBase58Check, toBase58Check, toHex } from '@bsv/sdk/primitives/utils' -import LockingScript from '@bsv/sdk/script/LockingScript' -import OP from '@bsv/sdk/script/OP' import type ScriptChunk from '@bsv/sdk/script/ScriptChunk' import type ScriptTemplate from '@bsv/sdk/script/ScriptTemplate' -import UnlockingScript from '@bsv/sdk/script/UnlockingScript' import type Transaction from '@bsv/sdk/transaction/Transaction' import type { PubKeyHex, WalletInterface } from '@bsv/sdk/wallet/Wallet.interfaces' import { boundPreimage, resolveBoundSource, signatureScope } from './signing-context.js' diff --git a/packages/helpers/ts-templates/src/R1K1Wallet.ts b/packages/helpers/ts-templates/src/R1K1Wallet.ts index 2934aafc3..7ac778f95 100644 --- a/packages/helpers/ts-templates/src/R1K1Wallet.ts +++ b/packages/helpers/ts-templates/src/R1K1Wallet.ts @@ -1,13 +1,9 @@ +import { Signature, TransactionSignature } from '@bsv/sdk/primitives' +import { LockingScript, OP, Script, UnlockingScript } from '@bsv/sdk/script' import { hash160, hash256, sha256 } from '@bsv/sdk/primitives/Hash' import type PrivateKey from '@bsv/sdk/primitives/PrivateKey' -import Signature from '@bsv/sdk/primitives/Signature' -import TransactionSignature from '@bsv/sdk/primitives/TransactionSignature' import { toArray, toHex } from '@bsv/sdk/primitives/utils' -import LockingScript from '@bsv/sdk/script/LockingScript' -import OP from '@bsv/sdk/script/OP' -import Script from '@bsv/sdk/script/Script' import type ScriptTemplate from '@bsv/sdk/script/ScriptTemplate' -import UnlockingScript from '@bsv/sdk/script/UnlockingScript' import type Transaction from '@bsv/sdk/transaction/Transaction' import { R1_K1_K1_SLOT_OFFSET, diff --git a/packages/helpers/ts-templates/src/signing-context.ts b/packages/helpers/ts-templates/src/signing-context.ts index aa6e55712..26b709803 100644 --- a/packages/helpers/ts-templates/src/signing-context.ts +++ b/packages/helpers/ts-templates/src/signing-context.ts @@ -1,4 +1,4 @@ -import TransactionSignature from '@bsv/sdk/primitives/TransactionSignature' +import { TransactionSignature } from '@bsv/sdk/primitives' import type Script from '@bsv/sdk/script/Script' import type Transaction from '@bsv/sdk/transaction/Transaction' diff --git a/packages/overlays/topics/CHANGELOG.md b/packages/overlays/topics/CHANGELOG.md index f1cfe0c8b..d7dc86cba 100644 --- a/packages/overlays/topics/CHANGELOG.md +++ b/packages/overlays/topics/CHANGELOG.md @@ -9,6 +9,11 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### 1.8.5 candidate + +- Refresh the packed templates range to include its CommonJS interoperability + repair. Topic APIs, schemas and admission behavior are unchanged. + - Updates the packed workspace dependency candidate for the additive overlay persistence contract. Runtime behavior and defaults are unchanged; no consumer migration is required. - Advances the packed overlay dependency candidate for BASM validation hardening. Package runtime behavior is unchanged; no consumer migration is required. diff --git a/packages/overlays/topics/README.md b/packages/overlays/topics/README.md index db49f460a..2ba4e00cb 100644 --- a/packages/overlays/topics/README.md +++ b/packages/overlays/topics/README.md @@ -15,6 +15,10 @@ Requires Node.js 22 or newer. Install `@bsv/sdk` alongside this package to satisfy its peer dependency. The overlay engine, templates, and MongoDB driver are direct runtime dependencies. +The unpublished 1.8.5 candidate refreshes the packed templates range for its +CommonJS compatibility repair. Topic APIs, schemas and admission behavior are +unchanged; no API migration is required. + ## Quick start Register a managed topic on an overlay engine: diff --git a/packages/overlays/topics/package.json b/packages/overlays/topics/package.json index 822617af1..b6479a9b1 100644 --- a/packages/overlays/topics/package.json +++ b/packages/overlays/topics/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay-topics", - "version": "1.8.4", + "version": "1.8.5", "sideEffects": false, "engines": { "node": ">=22" diff --git a/scripts/check-template-consumers.mjs b/scripts/check-template-consumers.mjs new file mode 100644 index 000000000..58b820253 --- /dev/null +++ b/scripts/check-template-consumers.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { createCommandRunner } from './lib/command-runner.mjs' + +const run = createCommandRunner({ timeoutMs: 180_000, maxBufferBytes: 20 * 1024 * 1024 }) +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +// Executed in isolated consumers, so every class comes from the installed tarballs. +async function exercise(sdk, templates, check) { + const { Hash, LockingScript, OP, PrivateKey, ProtoWallet, PublicKey, Spend, Transaction } = sdk + const { MandalaAdmin, MandalaToken, MultiPushDrop, OpReturn, P2MSKH, R1K1Wallet } = templates + const privateKey = new PrivateKey(23) + const wallet = new ProtoWallet(privateKey) + const publicKeyHash = Hash.hash160(privateKey.toPublicKey().encode(true)) + const digest = script => Buffer.from(Hash.sha256(script.toBinary())).toString('hex') + const results = {} + + async function spend(name, lockingScript, unlocker) { + check.ok(lockingScript instanceof LockingScript) + const source = new Transaction() + source.addOutput({ lockingScript, satoshis: 1 }) + const transaction = new Transaction() + transaction.addInput({ sourceTransaction: source, sourceOutputIndex: 0, sequence: 0xffffffff }) + transaction.addOutput({ lockingScript: new LockingScript([{ op: OP.OP_TRUE }]), satoshis: 1 }) + const unlockingScript = await unlocker.sign(transaction, 0) + check.equal( + new Spend({ + sourceTXID: source.id('hex'), + sourceOutputIndex: 0, + sourceSatoshis: 1, + lockingScript, + transactionVersion: transaction.version, + otherInputs: [], + inputIndex: 0, + unlockingScript, + outputs: transaction.outputs, + inputSequence: 0xffffffff, + lockTime: transaction.lockTime + }).validate(), + true, + `${name} signature must execute against its source contract` + ) + results[name] = digest(lockingScript) + } + + const assetId = `${'ab'.repeat(32)}.0` + const token = new MandalaToken() + await spend('MandalaToken', token.lock(assetId, 1, publicKeyHash), token.unlock(privateKey)) + check.throws(() => token.lock(assetId, 0, publicKeyHash), /positive/) + const adminData = { kind: 'issue', assetId, amount: 1 } + await spend( + 'MandalaAdmin', + await MandalaAdmin.lock({ wallet, data: adminData }), + MandalaAdmin.unlock({ wallet, data: adminData }) + ) + const pushDrop = new MultiPushDrop(wallet) + const protocolID = [1, 'packed template consumer'] + await spend( + 'MultiPushDrop', + await pushDrop.lock([[1, 2, 3]], protocolID, 'fixture', ['self']), + pushDrop.unlock(protocolID, 'fixture', 'self') + ) + const counterparty = privateKey.toPublicKey().toString() + const { publicKey } = await wallet.getPublicKey({ + protocolID: [1, 'multi sig brc29'], + keyID: 'fixture', + counterparty, + forSelf: true + }) + const pubkeys = [publicKey, new PrivateKey(29).toPublicKey().toString()] + const multisig = new P2MSKH() + await spend( + 'P2MSKH', + multisig.lock( + undefined, + pubkeys.map(key => PublicKey.fromString(key)), + 1 + ), + multisig.unlock(wallet, { pubkeys, keyID: 'fixture', counterparty }) + ) + const recovery = new R1K1Wallet() + await spend( + 'R1K1Wallet', + await recovery.lock(Array(20).fill(7), publicKeyHash), + recovery.unlock({ path: 'k1', privateKey }) + ) + const opReturn = new OpReturn().lock(['packed', 'consumer']) + check.deepEqual(OpReturn.decode(opReturn), ['packed', 'consumer']) + results.OpReturn = digest(opReturn) + return results +} + +async function pack(directory, destination) { + const { stdout } = await run('pnpm', ['pack', '--json', '--pack-destination', destination], { + cwd: directory, + env: { ...process.env, npm_config_ignore_scripts: 'true' } + }) + return path.resolve(JSON.parse(stdout).filename) +} + +function consumerSource(format) { + const imports = + format === 'cjs' + ? `const sdk = require('@bsv/sdk') +const templates = require('@bsv/templates') +const { MandalaToken } = require('@bsv/templates/MandalaToken.ts') +const check = require('node:assert/strict')` + : `import * as sdk from '@bsv/sdk' +import * as templates from '@bsv/templates' +import { MandalaToken } from '@bsv/templates/MandalaToken.ts' +import check from 'node:assert/strict'` + return `${imports} +check.equal(MandalaToken, templates.MandalaToken) +;(${exercise.toString()})(sdk, templates, check).then(result => console.log(JSON.stringify(result))) +` +} + +const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'template-consumers-')) +try { + const templates = await pack(path.join(root, 'packages/helpers/ts-templates'), temporary) + const candidate = await pack(path.join(root, 'packages/sdk'), temporary) + const sdkManifest = JSON.parse( + await fs.readFile(path.join(root, 'packages/sdk/package.json'), 'utf8') + ) + const profiles = [ + { label: 'candidate', sdk: candidate, version: sdkManifest.version }, + { + label: 'published', + sdk: process.env.TEMPLATES_PUBLISHED_SDK_TARBALL ?? '@bsv/sdk@2.8.0', + version: '2.8.0' + } + ] + const results = [] + for (const profile of profiles) { + const cwd = path.join(temporary, profile.label) + await fs.mkdir(cwd) + await fs.writeFile(path.join(cwd, 'package.json'), '{"private":true,"type":"module"}\n') + await run( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + '--omit=dev', + templates, + profile.sdk + ], + { cwd } + ) + const installed = JSON.parse( + await fs.readFile(path.join(cwd, 'node_modules/@bsv/sdk/package.json'), 'utf8') + ) + assert.equal(installed.version, profile.version) + for (const format of ['cjs', 'mjs']) { + const filename = `consumer.${format}` + await fs.writeFile(path.join(cwd, filename), consumerSource(format)) + const { stdout } = await run(process.execPath, [filename], { cwd }) + results.push(JSON.parse(stdout)) + console.log( + `Verified packed templates script construction and signing: SDK ${profile.version}, ${format}.` + ) + } + } + for (const result of results.slice(1)) assert.deepEqual(result, results[0]) +} finally { + await fs.rm(temporary, { recursive: true, force: true }) +} From 730dbd5fb82439ea7dc1059992e810c5c79d91f0 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 06:27:30 -0700 Subject: [PATCH 009/127] fix(ci): provision compiler for patch coverage aggregation --- .github/workflows/ci.yml | 12 ++++++++++++ scripts/ci-orchestration.test.mjs | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 017ece2b8..fcf282445 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1087,6 +1087,18 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + cache: pnpm + # The patch gate compares emitted code to recognize declaration-only + # edits. Without its compiler it correctly fails closed, even for a + # types-only module that cannot produce executable LCOV points. + - name: Install the patch-coverage compiler from the frozen root graph + run: pnpm install --filter @bsv/ts-stack --frozen-lockfile --ignore-scripts + - name: Rebuild the audited patch-coverage compiler + run: pnpm --filter @bsv/ts-stack rebuild esbuild - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: coverage-* diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 4a5082ea8..9a6970dbc 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -178,3 +178,19 @@ test('fork PRs can produce the required Codecov status without a privileged work assert.doesNotMatch(workflow, /pull_request_target|id-token:\s*write/) assert.match(workflow, /run: >-\n node scripts\/patch-coverage\.mjs/) }) + +test('coverage aggregation installs its runtime-comparison compiler before enforcing patch coverage', () => { + const workflow = readFileSync(CI_PATH, 'utf8') + const aggregate = workflowJobBlocks(workflow).find(job => job.name === 'coverage-upload').source + const install = aggregate.indexOf( + 'run: pnpm install --filter @bsv/ts-stack --frozen-lockfile --ignore-scripts' + ) + const rebuild = aggregate.indexOf('run: pnpm --filter @bsv/ts-stack rebuild esbuild') + const enforce = aggregate.indexOf('node scripts/patch-coverage.mjs') + const pnpm = aggregate.indexOf('uses: pnpm/action-setup@') + const node = aggregate.indexOf('uses: actions/setup-node@') + assert.ok(pnpm >= 0 && node > pnpm && install > node) + assert.ok(rebuild > install && enforce > rebuild) + assert.match(aggregate, /--target 90/) + assert.doesNotMatch(aggregate.slice(0, enforce), /continue-on-error: true/) +}) From 566cbe381ceb105de888bd79c586e9e47e3c13aa Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 06:33:36 -0700 Subject: [PATCH 010/127] test(templates): make fixture allocation explicit --- scripts/check-template-consumers.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/check-template-consumers.mjs b/scripts/check-template-consumers.mjs index 58b820253..944bbc8cf 100644 --- a/scripts/check-template-consumers.mjs +++ b/scripts/check-template-consumers.mjs @@ -88,7 +88,10 @@ async function exercise(sdk, templates, check) { const recovery = new R1K1Wallet() await spend( 'R1K1Wallet', - await recovery.lock(Array(20).fill(7), publicKeyHash), + await recovery.lock( + Array.from({ length: 20 }, () => 7), + publicKeyHash + ), recovery.unlock({ path: 'k1', privateKey }) ) const opReturn = new OpReturn().lock(['packed', 'consumer']) From a67ed3761983e0e5754b08d4637c8b3c96d39593 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 06:45:12 -0700 Subject: [PATCH 011/127] docs(wallet): clarify required live sync chain declarations --- docs/guides/wallet-sync-reliability.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index fcf03284a..84092db98 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -62,9 +62,10 @@ There is no unbounded prefetch queue or hidden parallel merge. ## Identity, network and persistence safety -Known chain mismatches fail with the existing `WERR_NETWORK_CHAIN` before sync -state or user writes. A legacy provider that omits chain information retains its -legacy behavior; absence is not proof of a matching chain. Both sync directions +Missing, unrecognized or mismatched chain declarations fail with the existing +`WERR_NETWORK_CHAIN` before sync state or user writes. Live sync never infers a +provider's chain. Legacy providers without access capabilities retain exclusive +ownership; they still need matching chain declarations. Both sync directions honor returned `ProcessSyncChunkResult.error` values as well as thrown errors. Neither path advances counts or checkpoints after an error, and a nonterminal page that makes no checkpoint progress fails explicitly. From 7ebceacebf3b0d1da2c1704b2586146982d66c89 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 07:06:05 -0700 Subject: [PATCH 012/127] fix(sync): own byte snapshots and streamline page cost calculation --- .../src/storage/sync/SyncPageBudget.ts | 22 ++++++--- .../src/storage/sync/snapshotSyncPage.test.ts | 45 +++++++++++++++++++ .../src/storage/sync/snapshotSyncPage.ts | 2 +- 3 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.test.ts diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts index ceae71b52..0a3914cb6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts @@ -12,18 +12,26 @@ function marginalCost( phase: 'readMs' | 'commitMs', previousFixedMs: number ): { perRecordMs: number; fixedMs: number } { - const averageRecords = samples.reduce((sum, sample) => sum + sample.records, 0) / samples.length - const averageMs = samples.reduce((sum, sample) => sum + sample[phase], 0) / samples.length - const variance = samples.reduce((sum, sample) => sum + (sample.records - averageRecords) ** 2, 0) + let totalRecords = 0 + let totalMs = 0 + for (const sample of samples) { + totalRecords += sample.records + totalMs += sample[phase] + } + const averageRecords = totalRecords / samples.length + const averageMs = totalMs / samples.length + let variance = 0 + let covariance = 0 + for (const sample of samples) { + const delta = sample.records - averageRecords + variance += delta ** 2 + covariance += delta * (sample[phase] - averageMs) + } // Until page sizes differ there is no evidence that any cost is fixed. if (variance === 0) { const fixedMs = Math.min(previousFixedMs, averageMs) return { perRecordMs: (averageMs - fixedMs) / averageRecords, fixedMs } } - const covariance = samples.reduce( - (sum, sample) => sum + (sample.records - averageRecords) * (sample[phase] - averageMs), - 0 - ) const slope = Math.max(0, covariance / variance) const fixedMs = Math.max(0, averageMs - slope * averageRecords) const latest = samples.at(-1)! diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.test.ts new file mode 100644 index 000000000..6d704df42 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.test.ts @@ -0,0 +1,45 @@ +import type { RequestSyncChunkArgs, SyncChunk } from '../../sdk/WalletStorage.interfaces' +import { snapshotSyncPage } from './snapshotSyncPage' + +test.each(['array', 'typed view', 'Buffer view'] as const)( + 'owns %s bytes and checkpoint values across an asynchronous handoff', + async representation => { + const backing = representation === 'Buffer view' ? Buffer.from([9, 1, 2, 3, 8]) : new Uint8Array([9, 1, 2, 3, 8]) + const bytes = representation === 'array' ? [1, 2, 3] : backing.subarray(1, 4) + const since = new Date(10) + const updatedAt = new Date(20) + const args = { + identityKey: 'wallet', + fromStorageIdentityKey: 'source', + toStorageIdentityKey: 'destination', + maxItems: 1, + maxRoughSize: 1024, + since, + offsets: [{ name: 'transaction', offset: 1 }] + } as RequestSyncChunkArgs + // The snapshot owns incoming byte storage before downstream schema/proof + // validation. Runtime byte views must never keep a producer's backing store. + const chunk = { + fromStorageIdentityKey: 'source', + toStorageIdentityKey: 'destination', + userIdentityKey: 'wallet', + transactions: [{ rawTx: bytes, updated_at: updatedAt }] + } as unknown as SyncChunk + const snapshot = snapshotSyncPage(args, chunk) + await Promise.resolve() + bytes[0] = 7 + since.setTime(30) + updatedAt.setTime(40) + args.offsets[0].offset = 99 + + const copied = snapshot.chunk.transactions![0] + expect(Array.from(copied.rawTx!)).toEqual([1, 2, 3]) + expect(copied.updated_at.getTime()).toBe(20) + expect(snapshot.args.since?.getTime()).toBe(10) + expect(snapshot.args.offsets[0].offset).toBe(1) + copied.rawTx![1] = 6 + expect(bytes[1]).toBe(2) + expect(backing[0]).toBe(9) + expect(backing[4]).toBe(8) + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts b/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts index c657d8b46..80b873c50 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts @@ -8,7 +8,7 @@ function recordCopy(record: T): T { const value = result[key] let copy: unknown = value if (Array.isArray(value)) copy = value.slice() - else if (value instanceof Uint8Array) copy = value.slice() + else if (value instanceof Uint8Array) copy = new Uint8Array(value) else if (value != null && typeof value === 'object') { // Date's internal-slot check also accepts dates returned by an IndexedDB // implementation in another realm; arbitrary nested objects are invalid. From 9646645c970e20b95e03b50fef3b8b2077f410b6 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 07:06:06 -0700 Subject: [PATCH 013/127] test(templates): pin published SDK consumer artifact integrity --- packages/helpers/ts-templates/README.md | 6 +++-- scripts/check-template-consumers.mjs | 31 ++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/helpers/ts-templates/README.md b/packages/helpers/ts-templates/README.md index 6a105f8e4..c706a85a9 100644 --- a/packages/helpers/ts-templates/README.md +++ b/packages/helpers/ts-templates/README.md @@ -35,8 +35,10 @@ No API or script-encoding migration is required. `pnpm pack:check` runs synthetic signed spends from exact packed artifacts in clean CJS and ESM consumers with published SDK 2.8.0 and the candidate SDK. -For offline qualification, `TEMPLATES_PUBLISHED_SDK_TARBALL` may name a locally -verified SDK 2.8.0 tarball; the check verifies its installed version. +For offline qualification, `TEMPLATES_PUBLISHED_SDK_TARBALL` may name a local +SDK 2.8.0 tarball. The check verifies the reviewed registry SHA-512 digest and +installed version for either the downloaded or supplied artifact; resolver age +policy and lifecycle-script restrictions stay unchanged. ## Current Templates diff --git a/scripts/check-template-consumers.mjs b/scripts/check-template-consumers.mjs index 944bbc8cf..dd09ec0c0 100644 --- a/scripts/check-template-consumers.mjs +++ b/scripts/check-template-consumers.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -12,6 +13,34 @@ import { createCommandRunner } from './lib/command-runner.mjs' const run = createCommandRunner({ timeoutMs: 180_000, maxBufferBytes: 20 * 1024 * 1024 }) const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +async function publishedSdkTarball(destination) { + const supplied = process.env.TEMPLATES_PUBLISHED_SDK_TARBALL + const filename = supplied + ? path.resolve(supplied) + : path.join(destination, 'published-sdk-2.8.0.tgz') + let bytes + if (supplied) bytes = await fs.readFile(filename) + else { + // First-party packages already have an explicit workspace age exemption. + // npm's inherited global "before" cutoff cannot express that exception: + // install the reviewed, immutable artifact without changing resolver policy. + const response = await fetch('https://registry.npmjs.org/@bsv/sdk/-/sdk-2.8.0.tgz', { + redirect: 'error', + signal: AbortSignal.timeout(30_000) + }) + if (!response.ok) throw new Error(`Published SDK artifact returned HTTP ${response.status}`) + bytes = Buffer.from(await response.arrayBuffer()) + } + assert.equal(bytes.length, 4_052_784, 'Published SDK artifact size changed') + assert.equal( + createHash('sha512').update(bytes).digest('base64'), + 'pXavnJa8F5ozKSOwVIphLZrTEJgEADlfj8Yu9CIOsdVC/X+CuGfHFwK9I5egr/EC1D2KCxmAbGls9erpc637Yw==', + 'Published SDK artifact must match its reviewed registry integrity' + ) + if (!supplied) await fs.writeFile(filename, bytes) + return filename +} + // Executed in isolated consumers, so every class comes from the installed tarballs. async function exercise(sdk, templates, check) { const { Hash, LockingScript, OP, PrivateKey, ProtoWallet, PublicKey, Spend, Transaction } = sdk @@ -136,7 +165,7 @@ try { { label: 'candidate', sdk: candidate, version: sdkManifest.version }, { label: 'published', - sdk: process.env.TEMPLATES_PUBLISHED_SDK_TARBALL ?? '@bsv/sdk@2.8.0', + sdk: await publishedSdkTarball(temporary), version: '2.8.0' } ] From 2ac2a2e1ba0c81b5937a8506ea637eb1ed34ddaa Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 07:15:57 -0700 Subject: [PATCH 014/127] test(templates): fetch pinned SDK through npm package tooling --- scripts/check-template-consumers.mjs | 31 +++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/scripts/check-template-consumers.mjs b/scripts/check-template-consumers.mjs index dd09ec0c0..3790d6ea0 100644 --- a/scripts/check-template-consumers.mjs +++ b/scripts/check-template-consumers.mjs @@ -15,29 +15,32 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') async function publishedSdkTarball(destination) { const supplied = process.env.TEMPLATES_PUBLISHED_SDK_TARBALL - const filename = supplied - ? path.resolve(supplied) - : path.join(destination, 'published-sdk-2.8.0.tgz') - let bytes - if (supplied) bytes = await fs.readFile(filename) - else { + const filename = supplied ? path.resolve(supplied) : path.join(destination, 'bsv-sdk-2.8.0.tgz') + if (!supplied) { // First-party packages already have an explicit workspace age exemption. // npm's inherited global "before" cutoff cannot express that exception: - // install the reviewed, immutable artifact without changing resolver policy. - const response = await fetch('https://registry.npmjs.org/@bsv/sdk/-/sdk-2.8.0.tgz', { - redirect: 'error', - signal: AbortSignal.timeout(30_000) - }) - if (!response.ok) throw new Error(`Published SDK artifact returned HTTP ${response.status}`) - bytes = Buffer.from(await response.arrayBuffer()) + // use its package fetcher for the exact artifact, with scripts disabled, + // then verify the reviewed digest before any consumer installation. + await run( + 'npm', + [ + 'pack', + '--ignore-scripts', + '--json', + '--pack-destination', + destination, + 'https://registry.npmjs.org/@bsv/sdk/-/sdk-2.8.0.tgz' + ], + { cwd: destination } + ) } + const bytes = await fs.readFile(filename) assert.equal(bytes.length, 4_052_784, 'Published SDK artifact size changed') assert.equal( createHash('sha512').update(bytes).digest('base64'), 'pXavnJa8F5ozKSOwVIphLZrTEJgEADlfj8Yu9CIOsdVC/X+CuGfHFwK9I5egr/EC1D2KCxmAbGls9erpc637Yw==', 'Published SDK artifact must match its reviewed registry integrity' ) - if (!supplied) await fs.writeFile(filename, bytes) return filename } From e88f5f41139f657a02326c5fb469ce44fd3bdc53 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 07:43:50 -0700 Subject: [PATCH 015/127] test(auth): cover raw-body verification and application handoff --- .../src/__tests/RawAuthMiddleware.test.ts | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 packages/middleware/auth-express-middleware/src/__tests/RawAuthMiddleware.test.ts diff --git a/packages/middleware/auth-express-middleware/src/__tests/RawAuthMiddleware.test.ts b/packages/middleware/auth-express-middleware/src/__tests/RawAuthMiddleware.test.ts new file mode 100644 index 000000000..aefdfa282 --- /dev/null +++ b/packages/middleware/auth-express-middleware/src/__tests/RawAuthMiddleware.test.ts @@ -0,0 +1,230 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { PrivateKey } from '@bsv/sdk' +import type { NextFunction, Response } from 'express' +import { createAuthMiddleware, ExpressTransport, type AuthRequest } from '../index.js' +import { RawRequestBodyError } from '../rawRequestBody.js' +import { MockWallet } from './MockWallet.js' + +const identityKey = new PrivateKey(1).toPublicKey().toString() + +function request(headers: Record = {}, path = '/paid') { + return Object.assign(new PassThrough(), { headers, path, method: 'POST', body: undefined }) +} + +function response() { + const res = Object.assign(new EventEmitter(), { + headersSent: false, + destroyed: false, + setHeader: jest.fn(), + status: jest.fn(), + json: jest.fn() + }) + res.status.mockReturnValue(res) + res.json.mockReturnValue(res) + return res +} + +async function flush() { + await new Promise(resolve => setImmediate(resolve)) +} + +describe('raw authentication middleware composition', () => { + afterEach(() => jest.restoreAllMocks()) + + it('rejects a truthy non-boolean raw-capture option', () => { + expect(() => + createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: 'true' as unknown as boolean + }) + ).toThrow('captureRawBody must be a boolean') + }) + + it('preserves exact bytes until verification completes, then decodes and advertises support', async () => { + let verified!: NextFunction + const transport = jest + .spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + .mockImplementation(async (req, _res, next) => { + expect(req.body).toEqual(Buffer.from(' { "message": "hello ☃" }\n')) + expect(req.rawBody).toBeUndefined() + expect(req.auth).toBeUndefined() + verified = next + }) + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: true + }) + const req = request({ 'content-type': 'application/json' }) + const res = response() + const next = jest.fn() + middleware(req as unknown as AuthRequest, res as unknown as Response, next) + req.end(Buffer.from(' { "message": "hello ☃" }\n')) + await flush() + expect(transport).toHaveBeenCalledTimes(1) + expect(next).not.toHaveBeenCalled() + const authRequest = req as unknown as AuthRequest + authRequest.auth = { identityKey } + verified() + expect(next).toHaveBeenCalledWith() + expect(authRequest.body).toEqual({ message: 'hello ☃' }) + expect(authRequest.rawBody).toEqual(Buffer.from(' { "message": "hello ☃" }\n')) + expect(authRequest.auth).toEqual({ identityKey, supportsMultipart: true }) + res.emit('finish') + }) + + it.each([undefined, 'unknown'])( + 'never advertises verified support for identity %s', + async identity => { + jest + .spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + .mockImplementation(async (req, _res, next) => { + if (identity !== undefined) req.auth = { identityKey: identity } + next() + }) + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: true + }) + const req = request({ 'content-type': 'text/plain' }) + const res = response() + const next = jest.fn() + middleware(req as unknown as AuthRequest, res as unknown as Response, next) + req.end(Buffer.from('public body')) + await flush() + expect(next).toHaveBeenCalledWith() + expect(req.body).toBe('public body') + expect((req as unknown as AuthRequest).auth?.supportsMultipart).toBeUndefined() + res.emit('finish') + } + ) + + it.each([ + [ + 'multipart/form-data; boundary="outer"', + Buffer.from('--outer\r\nopaque payload\r\n--outer--\r\n') + ], + ['application/octet-stream', Buffer.from([0, 255, 1])], + [undefined, Buffer.from([0, 255, 1])] + ])('retains opaque application bytes for %s', async (contentType, bytes) => { + jest + .spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + .mockImplementation(async (req, _res, next) => { + req.auth = { identityKey } + next() + }) + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: true + }) + const req = request(contentType === undefined ? {} : { 'content-type': contentType as string }) + const res = response() + const next = jest.fn() + middleware(req as unknown as AuthRequest, res as unknown as Response, next) + req.end(bytes) + await flush() + expect(next).toHaveBeenCalledWith() + expect(req.body).toEqual(bytes) + expect((req as unknown as AuthRequest).rawBody).toEqual(bytes) + res.emit('finish') + }) + + it('rejects malformed authenticated JSON before calling the application', async () => { + jest + .spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + .mockImplementation(async (req, _res, next) => { + req.auth = { identityKey } + next() + }) + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: true + }) + const req = request({ 'content-type': 'application/json' }) + const res = response() + const next = jest.fn() + middleware(req as unknown as AuthRequest, res as unknown as Response, next) + req.end(Buffer.from('{')) + await flush() + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ERR_AUTH_MALFORMED' })) + res.emit('finish') + }) + + it('decodes the bounded handshake envelope without marking it as an application payload', async () => { + const transport = jest + .spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + .mockImplementation(async (req, _res, next) => { + expect(req.body).toEqual({ messageType: 'initialRequest' }) + next() + }) + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: true + }) + const req = request({ 'content-type': 'application/json' }, '/.well-known/auth') + const res = response() + const next = jest.fn() + middleware(req as unknown as AuthRequest, res as unknown as Response, next) + req.end(Buffer.from('{"messageType":"initialRequest"}')) + await flush() + expect(transport).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith() + expect((req as unknown as AuthRequest).rawBody).toBeUndefined() + expect((req as unknown as AuthRequest).auth).toBeUndefined() + res.emit('finish') + }) + + it('forwards failed verification without decoding bytes or granting capability', async () => { + const failure = new Error('signature rejected') + jest + .spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + .mockImplementation(async (_req, _res, next) => next(failure)) + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: true + }) + const req = request({ 'content-type': 'application/json' }) + const res = response() + const next = jest.fn() + middleware(req as unknown as AuthRequest, res as unknown as Response, next) + req.end(Buffer.from('{"private":true}')) + await flush() + expect(next).toHaveBeenCalledWith(failure) + expect(req.body).toEqual(Buffer.from('{"private":true}')) + expect((req as unknown as AuthRequest).rawBody).toBeUndefined() + expect((req as unknown as AuthRequest).auth).toBeUndefined() + res.emit('finish') + }) + + it.each(['open', 'sent', 'destroyed'] as const)( + 'handles raw-body refusal with a %s response', + async state => { + const transport = jest.spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: true + }) + const req = request({ 'content-encoding': 'gzip' }) + const res = response() + res.headersSent = state === 'sent' + res.destroyed = state === 'destroyed' + const next = jest.fn() + middleware(req as unknown as AuthRequest, res as unknown as Response, next) + await flush() + expect(transport).not.toHaveBeenCalled() + if (state === 'open') { + expect(next).not.toHaveBeenCalled() + expect(res.setHeader).toHaveBeenCalledWith('Connection', 'close') + expect(res.status).toHaveBeenCalledWith(415) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ERR_AUTH_BODY' })) + } else { + expect(next).toHaveBeenCalledWith(expect.any(RawRequestBodyError)) + expect(res.json).not.toHaveBeenCalled() + expect(res.setHeader).not.toHaveBeenCalled() + } + req.destroy() + } + ) +}) From 651eab485596cfa783d8bbcebb9715d5bbc51431 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 08:25:43 -0700 Subject: [PATCH 016/127] fix(mobile): reproduce Hermes output and reduce sync callback overhead --- .../sdk/src/auth/utils/paymentTransport.ts | 24 +++++++------- .../wallet/wallet-toolbox/mobile/README.md | 4 +++ .../src/storage/WalletStorageManager.ts | 33 +++++++++---------- .../src/storage/sync/SyncPageBudget.ts | 8 ++--- .../src/storage/sync/snapshotSyncPage.ts | 2 +- .../src/storage/sync/syncSession.ts | 2 +- scripts/check-wallet-toolbox-platform.mjs | 19 +++++++++-- 7 files changed, 55 insertions(+), 37 deletions(-) diff --git a/packages/sdk/src/auth/utils/paymentTransport.ts b/packages/sdk/src/auth/utils/paymentTransport.ts index 2b88be669..c0f5056fc 100644 --- a/packages/sdk/src/auth/utils/paymentTransport.ts +++ b/packages/sdk/src/auth/utils/paymentTransport.ts @@ -72,12 +72,12 @@ export function paymentTransports(advertisement: string | null): ReadonlySet value.trim()) - .filter(value => value === 'header' || value === 'multipart') - ) + const transports = new Set() + for (const part of advertisement.split(',')) { + const value = part.trim() + if (value === 'header' || value === 'multipart') transports.add(value) + } + return transports } /** Keep released non-multipart preimages; bind the exact multipart boundary and parameters. */ @@ -193,11 +193,10 @@ export function preparePaymentTransport( } else headers['x-bsv-payment'] = paymentJSON if (body !== undefined && body.length > limits.maxBodyBytes) throw new PaymentTransportError('ERR_PAYMENT_SIZE', 'Paid request exceeds the body limit.') - const headerBytes = Object.entries(headers).reduce( - (total, [name, value]) => - total + toArray(name, 'utf8').length + toArray(value, 'utf8').length + 4, - 4096 - ) + let headerBytes = 4096 + for (const [name, value] of Object.entries(headers)) { + headerBytes += toArray(name, 'utf8').length + toArray(value, 'utf8').length + 4 + } if (headerBytes > limits.maxRequestHeaderBytes) throw new PaymentTransportError( 'ERR_PAYMENT_SIZE', @@ -242,7 +241,8 @@ export function buildMultipartPayment( ) } pieces.push(utf8(`\r\n--${boundary}--\r\n`)) - const length = pieces.reduce((total, piece) => total + piece.length, 0) + let length = 0 + for (const piece of pieces) length += piece.length if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || length > maximumBytes) { throw new PaymentTransportError( 'ERR_PAYMENT_SIZE', diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index dfd26e796..a2bce2f25 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -54,6 +54,10 @@ The package publishes: The packed package is validated with Metro and compiled to optimized Hermes bytecode. Node.js 22 or newer is required for the published tooling and contributor workflow, not as an on-device runtime. +The Hermes probe uses a stable relative input filename and verifies identical +bytecode from two independent build directories. Source maps and debug data are +retained; random temporary paths must not affect the unchanged size budgets. + ### Password derivation without WebAssembly Argon2id password derivation uses `hash-wasm` when WebAssembly is available. diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index 732b57572..808731a34 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -710,7 +710,7 @@ export class WalletStorageManager implements sdk.WalletStorage { })) // Bound external work and leave foreground storage access available while // providers fetch proofs/headers. Every replacement is validated before SQL/IDB. - const prepared = await mapProofWork(ptxs, async ptx => await this.prepareReproof(storage, ptx)) + const prepared = await mapProofWork(ptxs, ptx => this.prepareReproof(storage, ptx)) const result: sdk.ReproveHeaderResult = { log: ` ${label} with ${ptxs.length} impacted transactions\n`, updated: [], @@ -824,8 +824,8 @@ export class WalletStorageManager implements sdk.WalletStorage { log += `syncFromReader from ${readerSettings.storageName} to ${writerSettings.storageName}\n` - const loadRequest = async (): Promise => - await this.loadSyncRequest(auth, writer, readerSettings, writerSettings.storageIdentityKey) + const loadRequest = (): Promise => + this.loadSyncRequest(auth, writer, readerSettings, writerSettings.storageIdentityKey) let args = await loadRequest() const budget = new SyncPageBudget() let i = -1 @@ -896,18 +896,17 @@ export class WalletStorageManager implements sdk.WalletStorage { ) } } - const run = async (commit: (operation: () => Promise) => Promise): Promise => - await runPullSession( + const run = (commit: (operation: () => Promise) => Promise): Promise => + runPullSession( { reader, writer, activeStorage, atomicCheckpoint, mode: paged ? 'paged' : 'exclusive', - loadRequest: async () => - await this.loadSyncRequest(auth, writer, readerSettings, writerSettings.storageIdentityKey), + loadRequest: () => this.loadSyncRequest(auth, writer, readerSettings, writerSettings.storageIdentityKey), prepare: paged - ? async (args, chunk) => await (writer as StorageProvider).prepareSyncChunk(args, chunk) + ? (args, chunk) => (writer as StorageProvider).prepareSyncChunk(args, chunk) : undefined, commit }, @@ -915,22 +914,22 @@ export class WalletStorageManager implements sdk.WalletStorage { ) if (paged) { return await run( - async operation => - await this.withAccess( - async () => { + operation => + this.withAccess( + () => { assertCurrent() - return await operation() + return operation() }, false, true ) ) } - return await this.runAsSync(async () => { + return await this.runAsSync(() => { assertCurrent() - return await run(async operation => { + return run(operation => { assertCurrent() - return await operation() + return operation() }) }) } @@ -958,8 +957,8 @@ export class WalletStorageManager implements sdk.WalletStorage { log += progLog(`syncToWriter from ${readerSettings.storageName} to ${writerSettings.storageName}\n`) - const loadRequest = async (): Promise => - await this.loadSyncRequest(auth, writer, readerSettings, writerSettings.storageIdentityKey) + const loadRequest = (): Promise => + this.loadSyncRequest(auth, writer, readerSettings, writerSettings.storageIdentityKey) let args = await loadRequest() const budget = new SyncPageBudget() let i = -1 diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts index 0a3914cb6..78d29ae2b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts @@ -60,10 +60,10 @@ export class SyncPageBudget { committed(chunk: SyncChunk, elapsedMs: number, readMs = 0): void { if (!Number.isFinite(elapsedMs) || elapsedMs < 0 || !Number.isFinite(readMs) || readMs < 0 || readMs > elapsedMs) return - const records = Object.values(chunk).reduce( - (count, value) => count + (Array.isArray(value) ? value.length : 0), - 0 - ) + let records = 0 + for (const value of Object.values(chunk)) { + if (Array.isArray(value)) records += value.length + } if (records === 0) return const proofs = (chunk.provenTxs?.length ?? 0) > 0 // Metadata throughput does not predict network-backed proof checks. diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts b/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts index 80b873c50..57a7576ad 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/snapshotSyncPage.ts @@ -30,7 +30,7 @@ export function snapshotSyncPage( const snapshot = { ...chunk } for (const key of Object.keys(snapshot) as Array) { const value = snapshot[key] - if (Array.isArray(value)) Object.assign(snapshot, { [key]: value.map(record => recordCopy(record)) }) + if (Array.isArray(value)) Object.assign(snapshot, { [key]: value.map(recordCopy) }) } if (chunk.user != null) snapshot.user = recordCopy(chunk.user) return { diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts index 8e131c252..e22614bae 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts @@ -96,7 +96,7 @@ async function readAndPreparePage( const prepareAt = Date.now() const apply = session.prepare == null - ? async () => await session.writer.processSyncChunk(pageArgs, chunk) + ? () => session.writer.processSyncChunk(pageArgs, chunk) : await session.prepare(pageArgs, chunk) const prepareMs = Date.now() - prepareAt if (cancelled()) return undefined diff --git a/scripts/check-wallet-toolbox-platform.mjs b/scripts/check-wallet-toolbox-platform.mjs index 2b7ed9223..51e7b5758 100644 --- a/scripts/check-wallet-toolbox-platform.mjs +++ b/scripts/check-wallet-toolbox-platform.mjs @@ -487,9 +487,24 @@ async function checkMobile(consumerDirectory, budget) { const hermesPath = hermesCompilerPath() const bytecodePath = path.join(consumerDirectory, 'wallet-toolbox-mobile.hbc') - await run(hermesPath, ['-O', '-emit-binary', '-out', bytecodePath, bundlePath]) - const hermesBytecode = await fs.readFile(bytecodePath) + // Hermes retains its input filename in bytecode. A random absolute temporary + // path makes identical bundles cross compression budgets nondeterministically. + // Keep source/debug data and compile from a stable relative filename instead. + const compile = async directory => { + await run( + hermesPath, + ['-O', '-emit-binary', '-out', path.basename(bytecodePath), path.basename(bundlePath)], + { cwd: directory } + ) + return fs.readFile(path.join(directory, path.basename(bytecodePath))) + } + const hermesBytecode = await compile(consumerDirectory) if (hermesBytecode.length === 0) throw new Error('Hermes compiler emitted empty bytecode') + const repeatDirectory = await fs.mkdtemp(path.join(consumerDirectory, 'hermes-repro-')) + await fs.copyFile(bundlePath, path.join(repeatDirectory, path.basename(bundlePath))) + if (!hermesBytecode.equals(await compile(repeatDirectory))) { + throw new Error('Hermes bytecode must reproduce across independent build directories') + } const hermesSizes = sizes(hermesBytecode) validateBudget(hermesSizes, budget.hermes, 'Hermes mobile bytecode') From 2a232702f1e5603c5af6ad1b557cb09ee7074b84 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 08:25:43 -0700 Subject: [PATCH 017/127] test(auth): cover absent bodies and configured raw request limits --- .../src/__tests/RawAuthMiddleware.test.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/middleware/auth-express-middleware/src/__tests/RawAuthMiddleware.test.ts b/packages/middleware/auth-express-middleware/src/__tests/RawAuthMiddleware.test.ts index aefdfa282..778c2820d 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/RawAuthMiddleware.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/RawAuthMiddleware.test.ts @@ -105,8 +105,9 @@ describe('raw authentication middleware composition', () => { Buffer.from('--outer\r\nopaque payload\r\n--outer--\r\n') ], ['application/octet-stream', Buffer.from([0, 255, 1])], - [undefined, Buffer.from([0, 255, 1])] - ])('retains opaque application bytes for %s', async (contentType, bytes) => { + [undefined, Buffer.from([0, 255, 1])], + [undefined, undefined] + ])('retains an opaque or absent body for %s (case %#)', async (contentType, bytes) => { jest .spyOn(ExpressTransport.prototype, 'handleIncomingRequest') .mockImplementation(async (req, _res, next) => { @@ -129,6 +130,26 @@ describe('raw authentication middleware composition', () => { res.emit('finish') }) + it('applies configured raw-byte limits before authentication or application dispatch', async () => { + const transport = jest.spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + captureRawBody: true, + transportLimits: { maxRequestBytes: 3, requestTimeoutMs: 100, maxPendingRequests: 1 } + }) + const req = request({ 'content-length': '4' }) + const res = response() + const next = jest.fn() + middleware(req as unknown as AuthRequest, res as unknown as Response, next) + await flush() + expect(transport).not.toHaveBeenCalled() + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(413) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ERR_AUTH_BODY' })) + expect((req as unknown as AuthRequest).auth).toBeUndefined() + req.destroy() + }) + it('rejects malformed authenticated JSON before calling the application', async () => { jest .spyOn(ExpressTransport.prototype, 'handleIncomingRequest') From 94eccbeaf8918e92487878239da0347d47cd8e6d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 18:14:52 -0700 Subject: [PATCH 018/127] fix(auth): align empty binary request preimages after Express parsing --- docs/guides/brc118-payments.md | 9 +++- docs/packages/sdk/bsv-sdk.md | 2 +- docs/reference/package-api-migrations.md | 12 ++--- governance/mutation-testing/targets.mjs | 2 +- governance/package-release-notes.json | 8 ++-- .../auth-express-middleware/README.md | 14 +++++- .../src/__tests/authMiddlewareHelpers.test.ts | 16 +++++++ .../src/__tests/integration.test.ts | 45 +++++++++++++++++++ .../src/__tests/testExpressServer.ts | 2 +- .../src/authMiddlewareHelpers.ts | 6 ++- packages/sdk/CHANGELOG.md | 6 ++- packages/sdk/README.md | 3 +- 12 files changed, 106 insertions(+), 19 deletions(-) diff --git a/docs/guides/brc118-payments.md b/docs/guides/brc118-payments.md index 0c53df844..d4a881596 100644 --- a/docs/guides/brc118-payments.md +++ b/docs/guides/brc118-payments.md @@ -75,7 +75,14 @@ fit the receiver's bounded multipart profile (at most 128 parts, ASCII part headers); applications needing a broader upload format should use a separate upload route and pay for its resulting resource. -Existing non-multipart signature preimages retain their released normalization. +Existing nonempty non-multipart signature preimages retain their released normalization. +Empty byte requests now consistently use BRC-104's `-1` sentinel in AuthFetch +and auth middleware, including the existing `express.raw` integration without +raw capture. SDK clients and auth receivers that previously signed/reconstructed +length `0` for empty byte arrays must adopt SDK 2.9.0 and auth middleware 2.3.0 +together for those requests. This correction does not change nonempty bodies or +multipart payload-part bytes; an empty multipart payload part remains distinct +from a missing part. Multipart authenticates the exact Content-Type value. There is no second, weaker verification attempt with the boundary removed. diff --git a/docs/packages/sdk/bsv-sdk.md b/docs/packages/sdk/bsv-sdk.md index 04e8fb694..c06d12880 100644 --- a/docs/packages/sdk/bsv-sdk.md +++ b/docs/packages/sdk/bsv-sdk.md @@ -15,7 +15,7 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk' # @bsv/sdk -The unpublished 2.9 candidate adds bounded BRC-118 payment transport to `AuthFetch` and corrects recipient-side BRC-29 derivation. See the [BRC-118 integration and migration guide](../../guides/brc118-payments.md) for preparation, negotiation, exact-byte authentication and uncertain-payment recovery. Existing non-multipart signing preimages and the 8 KiB header selection default are preserved. +The unpublished 2.9 candidate adds bounded BRC-118 payment transport to `AuthFetch` and corrects recipient-side BRC-29 derivation. See the [BRC-118 integration and migration guide](../../guides/brc118-payments.md) for preparation, negotiation, exact-byte authentication and uncertain-payment recovery. Existing nonempty non-multipart signing preimages and the 8 KiB header selection default are preserved. Version 2.8.1 repairs portable decryption of authenticated empty AES-GCM plaintext. Browser/mobile and native Node envelopes now interoperate diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 02967c37e..18b9ff5b8 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -29,7 +29,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | | `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | | `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.5` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. | +| `@bsv/auth-express-middleware` | `2.2.5` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | | `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/authsocket-client` | `2.1.7` | `2.1.7` | none | [API and usage](../packages/messaging/authsocket-client.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. The connect event/connected property report Socket.IO transport state, not completed BRC-103 authentication; wait for verified application traffic when local behavior requires a known unpinned server. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | | `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | @@ -48,7 +48,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/overlay-topics` | `1.8.4` | `1.8.5` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | | `@bsv/paymail` | `2.4.10` | `2.4.10` | none | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | | `@bsv/payment-express-middleware` | `2.1.7` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.2` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and non-multipart signing retain their wire format. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. | +| `@bsv/sdk` | `2.8.2` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. | | `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | | `@bsv/templates` | `1.10.3` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | | `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | @@ -117,8 +117,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/auth-express-middleware.md](../packages/middleware/auth-express-middleware.md) - Source: [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) -- Release note: Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. -- Migration: Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. +- Release note: Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. Normalizes zero-length dense byte arrays and Uint8Array/Buffer bodies to the BRC-104 -1 sentinel, matching AuthFetch when express.raw runs before authentication. +- Migration: Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -359,8 +359,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/sdk/bsv-sdk.md](../packages/sdk/bsv-sdk.md) - Source: [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) -- Release note: Adds BRC-118 negotiated, byte-preserving multipart payments to AuthFetch with bounded header/body budgets, prepare-before-broadcast validation, one-transaction submission/retry, cancellation and typed permanent recovery outcomes. Preserves non-multipart signature preimages and fixes BRC-29 recipient child-key derivation. Adds independent Python wire/preimage vectors and composed authenticated HTTP/proxy regression coverage. -- Migration: Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and non-multipart signing retain their wire format. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. +- Release note: Adds BRC-118 negotiated, byte-preserving multipart payments to AuthFetch with bounded header/body budgets, prepare-before-broadcast validation, one-transaction submission/retry, cancellation and typed permanent recovery outcomes. Preserves nonempty non-multipart signature preimages and fixes BRC-29 recipient child-key derivation. Adds independent Python wire/preimage vectors and composed authenticated HTTP/proxy regression coverage. +- Migration: Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 5f65cb255..a54a5ddc5 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -423,7 +423,7 @@ export function buildMutationTargets(repositoryRoot) { manifest: 'packages/middleware/auth-express-middleware/package.json', propertyTest: 'packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts', - mutate: ['src/authMiddlewareHelpers.ts:97-104', 'src/authMiddlewareHelpers.ts:187-214'], + mutate: ['src/authMiddlewareHelpers.ts:97-104', 'src/authMiddlewareHelpers.ts:178-216'], ...jestTarget('jest.config.js', ['/src/__tests/authMiddlewareHelpers*.test.ts']) }, 'payment-replay': { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index b5229f7e7..8f998eaf4 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -35,8 +35,8 @@ "name": "@bsv/auth-express-middleware", "publishedVersion": "2.2.5", "releaseType": "minor", - "summary": "Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior.", - "migration": "Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate." + "summary": "Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. Normalizes zero-length dense byte arrays and Uint8Array/Buffer bodies to the BRC-104 -1 sentinel, matching AuthFetch when express.raw runs before authentication.", + "migration": "Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged." }, { "name": "@bsv/authsocket", @@ -168,8 +168,8 @@ "name": "@bsv/sdk", "publishedVersion": "2.8.2", "releaseType": "minor", - "summary": "Adds BRC-118 negotiated, byte-preserving multipart payments to AuthFetch with bounded header/body budgets, prepare-before-broadcast validation, one-transaction submission/retry, cancellation and typed permanent recovery outcomes. Preserves non-multipart signature preimages and fixes BRC-29 recipient child-key derivation. Adds independent Python wire/preimage vectors and composed authenticated HTTP/proxy regression coverage.", - "migration": "Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and non-multipart signing retain their wire format. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins." + "summary": "Adds BRC-118 negotiated, byte-preserving multipart payments to AuthFetch with bounded header/body budgets, prepare-before-broadcast validation, one-transaction submission/retry, cancellation and typed permanent recovery outcomes. Preserves nonempty non-multipart signature preimages and fixes BRC-29 recipient child-key derivation. Adds independent Python wire/preimage vectors and composed authenticated HTTP/proxy regression coverage.", + "migration": "Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins." }, { "name": "@bsv/simple", diff --git a/packages/middleware/auth-express-middleware/README.md b/packages/middleware/auth-express-middleware/README.md index 9f7c009da..c03c57a16 100644 --- a/packages/middleware/auth-express-middleware/README.md +++ b/packages/middleware/auth-express-middleware/README.md @@ -15,12 +15,24 @@ Set `captureRawBody: true` and install this middleware **before body parsers** when composing BRC-118 payments. It collects bounded raw bytes, authenticates the whole body and exact multipart Content-Type (including boundary), then exposes `req.rawBody` and the verified `req.auth.supportsMultipart` marker. Existing -non-multipart signature preimages and the default parsed-body integration remain +nonempty non-multipart signature preimages and the default parsed-body integration remain compatible. Pair with payment middleware `enableMultipart: true`; raw auth alone does not advertise payment support. Collection enforces request size, aggregate memory, pending-request and timeout limits before authentication. See the [BRC-118 guide](../../../docs/guides/brc118-payments.md) for limits, CORS and migration. +## Empty binary request bodies + +Auth middleware 2.3.0 signs zero-length dense byte arrays and `Uint8Array`/`Buffer` +values with BRC-104's `-1` body-length sentinel, matching SDK AuthFetch 2.9.0. +This includes `express.raw({ type: 'application/octet-stream' })` mounted before +authentication without `captureRawBody`: Express may expose an empty Buffer even +when HTTP carries no body. Nonempty byte bodies keep their existing preimages. +For empty binary requests, upgrade the SDK client and auth receiver together; +older clients that sign length `0` or receivers that reconstruct length `0` +require the paired correction. No application payload or persisted-data migration +is required. + ## Requirements - Node.js 22 or newer diff --git a/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.test.ts b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.test.ts index c6acf5387..0c218f6a5 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.test.ts @@ -173,6 +173,22 @@ describe('auth middleware helpers', () => { expect(readBody(writer)).toEqual(expected) }) + it.each([ + ['dense byte array', []], + ['Uint8Array', new Uint8Array(0)], + ['Buffer', Buffer.alloc(0)], + ['empty subarray', new Uint8Array([0, 255]).subarray(1, 1)] + ])('writes the absent-body sentinel for an empty %s', (_name, body) => { + const writer = new Utils.Writer() + writeBodyToWriter( + { body, headers: { 'content-type': 'application/octet-stream' } } as any, + writer + ) + const reader = new Utils.Reader(writer.toArray()) + expect(reader.readVarIntNum()).toBe(-1) + expect(reader.pos).toBe(reader.bin.length) + }) + it.each([ [undefined, undefined], ['', 'text/plain'], diff --git a/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts b/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts index 23a087e03..8adcd7d2c 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts @@ -84,6 +84,51 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { await expect(authFetch.fetch(`${origin}/empty-404`)).rejects.toThrow(/signature/i) }) + describe.each(['POST', 'PUT'])('%s through express.raw without captureRawBody', method => { + test.each([ + ['dense byte array', [], []], + ['Uint8Array', new Uint8Array(0), []], + ['Buffer', Buffer.alloc(0), []], + ['absent body', undefined, []], + ['nonempty subarray', new Uint8Array([99, 0, 255, 88]).subarray(1, 3), [0, 255]] + ])('authenticates a %s body and the signed response', async (_name, body, expected) => { + const authFetch = new AuthFetch(new MockWallet(privKey)) + const route = method === 'PUT' ? '/put-endpoint' : '/other-endpoint' + const result = await authFetch.fetch(`${origin}${route}`, { + method, + headers: { 'content-type': 'application/octet-stream' }, + body: body as unknown as BodyInit + }) + expect(result.status).toBe(200) + expect(result.headers.get('x-bsv-auth-identity-key')).toBeTruthy() + const response = await result.json() + if (method === 'PUT') expect(response.body).toEqual(expected) + }) + }) + + test('rejects an empty octet-stream request changed to a nonempty body in transit', async () => { + const tamper: typeof fetch = async (url, init) => + fetch( + url, + String(url).endsWith('/put-endpoint') ? { ...init, body: new Uint8Array([1]) } : init + ) + const authFetch = new AuthFetch( + new MockWallet(privKey), + undefined, + undefined, + undefined, + {}, + tamper + ) + await expect( + authFetch.fetch(`${origin}/put-endpoint`, { + method: 'PUT', + headers: { 'content-type': 'application/octet-stream' }, + body: new Uint8Array(0) + }) + ).rejects.toThrow(/signature|authentication/i) + }) + test('Test 1: Simple POST request with JSON', async () => { const walletWithRequests = new MockWallet(privKey) const authFetch = new AuthFetch(walletWithRequests) diff --git a/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts b/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts index 30000829d..e50fdd688 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts @@ -46,7 +46,7 @@ export const startServer = (_port = 3000): Server => { app.use(bodyParser.json()) app.use(express.urlencoded({ extended: true })) app.use(express.text()) - app.use(bodyParser.raw({ type: 'application/octet-stream', limit: '500mb' })) + app.use(express.raw({ type: 'application/octet-stream', limit: '500mb' })) // Mocked certificate and wallet setup // Used in the authentication middleware as needed: diff --git a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts index 6274f30e4..5c8970209 100644 --- a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts +++ b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts @@ -177,14 +177,16 @@ export function writeBodyToWriter( const byteArray = copyDenseByteArray(body) if (byteArray !== undefined) { - writer.writeVarIntNum(byteArray.length) + // HTTP cannot distinguish an absent body from zero transmitted bytes. + // Match AuthFetch's BRC-104 empty-body sentinel after Express raw parsing. + writer.writeVarIntNum(byteArray.length === 0 ? -1 : byteArray.length) writer.write(byteArray) debugLog('[writeBodyToWriter] Body recognized as number[]', { length: byteArray.length }) return } if (body instanceof Uint8Array) { - writer.writeVarIntNum(body.length) + writer.writeVarIntNum(body.length === 0 ? -1 : body.length) writer.write(Array.from(body)) debugLog('[writeBodyToWriter] Body recognized as Uint8Array', { length: body.length }) return diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 20141cea6..8d40370f5 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -217,10 +217,14 @@ All notable changes to this project will be documented in this file. The format ### 2.9.0 candidate — prepared BRC-118 payments and recipient interoperability - Negotiate bounded multipart payments, preserving original payload bytes and exact - signed boundary parameters while retaining non-multipart signature preimages. + signed boundary parameters while retaining nonempty non-multipart signature preimages. - Prepare the real payment and request before broadcast; submit once, reuse the transaction across retries, abort refused reservations where supported, and retain typed context for cancellation, size refusal and uncertain outcomes. +- Pair zero-length byte request normalization with auth middleware 2.3.0: both + use the BRC-104 `-1` sentinel, including the existing Express raw-parser path. + Empty-byte clients/receivers that used length `0` need a coordinated upgrade; + nonempty request preimages are unchanged. - Derive the recipient's own BRC-29 child key on settlement receipt. - Add independent Python wire/preimage vectors, real HTTP/proxy-limit tests and adversarial payment lifecycle coverage. See the BRC-118 guide for migration. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 3cb47062e..b76654b36 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -41,7 +41,8 @@ same payment with `sendWith`. The wallet must support prepare/submit and fail before broadcast; uncertain submission or delivery requires reconciliation instead of another automatic spend. See the [BRC-118 guide](../../docs/guides/brc118-payments.md) for receiver-first rollout, limits, cancellation, raw-byte payloads and typed -`PaymentTransportError` outcomes. Non-multipart authentication remains compatible. +`PaymentTransportError` outcomes. Nonempty non-multipart authentication remains compatible; empty byte bodies use +the BRC-104 `-1` sentinel and require the matching auth middleware 2.3.0 receiver. BRC-29 receipt derives the recipient's own child key (`forSelf: true`). Independent sender/recipient wallet tests protect this distinction; the payer's sibling From d76d7b8aa03d89437f77928c2b36622938a92fef Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 19:04:53 -0700 Subject: [PATCH 019/127] perf(wallet): reduce storage forwarding overhead within mobile budgets Preserve asynchronous transport errors, authorization timing and queue ownership with regression coverage. Reconcile the AuthSocket patch candidate for changed bundled SDK bytes and clarify the empty-body migration exception. --- docs/packages/messaging/authsocket-client.md | 12 +- docs/reference/package-api-migrations.md | 16 +-- docs/reference/stack-facts.md | 2 +- governance/package-release-notes.json | 14 +-- governance/repository-health/baselines.json | 2 +- .../messaging/authsocket-client/README.md | 8 ++ .../messaging/authsocket-client/package.json | 2 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 5 + .../src/storage/WalletStorageManager.ts | 37 +++--- .../__test/WalletStorageManager.test.ts | 113 ++++++++++++++++++ .../src/storage/remoting/StorageClientBase.ts | 110 ++++++++--------- .../StorageClientBase.forwarding.test.ts | 67 +++++++++++ .../storage/sync/StorageAccessQueue.test.ts | 36 ++++++ .../src/storage/sync/StorageAccessQueue.ts | 14 ++- .../src/storage/sync/syncCheckpoint.ts | 19 +-- 15 files changed, 349 insertions(+), 108 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.forwarding.test.ts diff --git a/docs/packages/messaging/authsocket-client.md b/docs/packages/messaging/authsocket-client.md index f47232144..a62825306 100644 --- a/docs/packages/messaging/authsocket-client.md +++ b/docs/packages/messaging/authsocket-client.md @@ -3,10 +3,10 @@ id: pkg-authsocket-client title: '@bsv/authsocket-client' kind: package domain: messaging -version: '2.1.7' +version: '2.1.8' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-24' +last_verified: '2026-09-24' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/authsocket-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client' @@ -18,6 +18,12 @@ tags: [messaging, websocket, brc-31, auth] > Client-side BRC-103 mutual authentication wrapper for socket.io-client. Signs all outbound messages and verifies inbound messages using a wallet, enabling authenticated peer-to-peer WebSocket communication. +## Next release candidate + +Unpublished 2.1.8 refreshes the UMD browser bundle with the integrated SDK. +The public API, signed event JSON and ESM/CommonJS SDK peer range are unchanged. +Existing wallet releases keep their published dependency pins. + ## Install ```bash diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 18b9ff5b8..1e6c5b297 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -29,9 +29,9 @@ and clean-consumer tests remain the executable type authority. | `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | | `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | | `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.5` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/auth-express-middleware` | `2.2.5` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | | `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.7` | none | [API and usage](../packages/messaging/authsocket-client.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. The connect event/connected property report Socket.IO transport state, not completed BRC-103 authentication; wait for verified application traffic when local behavior requires a known unpinned server. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | | `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | | `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | | `@bsv/chirp` | `0.1.2` | `0.1.2` | none | [API and usage](../packages/network/chirp.md) | Valid BRC-167 objects, CHIRP/UHRP locators, host endpoints, and ordinary typed calls retain their wire and API behavior. Runtime configuration/options and number-array bytes must use plain own data properties and exact documented types; malformed, accessor-backed, oversized, expired, or non-canonical values now fail closed. Use fetchClient to customize network transport while retaining AuthFetch; the legacy fetch callback replaces the full authenticated request path and should be limited to tests or a caller-supplied authenticated client. Custom caches and sinks receive owned bytes and cannot mutate returned verified results. Pass signal to build, publish, download, stream, or closure validation when external adapters must be cancellable. CLI resume files are bounded, non-symlink, atomic mode-0600 capabilities, and retrieval refuses to follow or replace an existing output path. Existing @bsv/sdk storage and UHRP routes remain unchanged; BRC-167 remains authoritative. | @@ -118,7 +118,7 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/auth-express-middleware.md](../packages/middleware/auth-express-middleware.md) - Source: [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) - Release note: Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. Normalizes zero-length dense byte arrays and Uint8Array/Buffer bodies to the BRC-104 -1 sentinel, matching AuthFetch when express.raw runs before authentication. -- Migration: Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. +- Migration: Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -141,8 +141,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/messaging/authsocket-client.md](../packages/messaging/authsocket-client.md) - Source: [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) -- Release note: Contains authentication and application callback failures, caps authentication concurrency, snapshots strict JSON before signing, serializes real typed arrays portably, preserves supported signed event JSON exactly, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. The connect event/connected property report Socket.IO transport state, not completed BRC-103 authentication; wait for verified application traffic when local behavior requires a known unpinned server. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. +- Release note: Refreshes the distributed UMD browser bundle with the integrated SDK implementation. The rebuilt bundle differs from published 2.1.7; the patch candidate keeps those new bytes under a new version. AuthSocket source APIs and event serialization are unchanged. +- Migration: No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -523,7 +523,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. - Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -537,7 +537,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. - Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -549,7 +549,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. - Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 362aee3ea..cc2498a7b 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -48,7 +48,7 @@ authorized release action. | helpers | `@bsv/wallet-helper` | `0.1.9` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | | helpers | `create-bsv-app` | `1.1.2` | cli | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | | messaging | `@bsv/authsocket` | `2.1.8` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | -| messaging | `@bsv/authsocket-client` | `2.1.7` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | +| messaging | `@bsv/authsocket-client` | `2.1.8` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | | messaging | `@bsv/message-box-client` | `2.6.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | | messaging | `@bsv/paymail` | `2.4.10` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) | | middleware | `@bsv/402-pay` | `0.3.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 8f998eaf4..aaa7945aa 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -36,7 +36,7 @@ "publishedVersion": "2.2.5", "releaseType": "minor", "summary": "Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. Normalizes zero-length dense byte arrays and Uint8Array/Buffer bodies to the BRC-104 -1 sentinel, matching AuthFetch when express.raw runs before authentication.", - "migration": "Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration and non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged." + "migration": "Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged." }, { "name": "@bsv/authsocket", @@ -48,9 +48,9 @@ { "name": "@bsv/authsocket-client", "publishedVersion": "2.1.7", - "releaseType": "none", - "summary": "Contains authentication and application callback failures, caps authentication concurrency, snapshots strict JSON before signing, serializes real typed arrays portably, preserves supported signed event JSON exactly, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. The connect event/connected property report Socket.IO transport state, not completed BRC-103 authentication; wait for verified application traffic when local behavior requires a known unpinned server. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it." + "releaseType": "patch", + "summary": "Refreshes the distributed UMD browser bundle with the integrated SDK implementation. The rebuilt bundle differs from published 2.1.7; the patch candidate keeps those new bytes under a new version. AuthSocket source APIs and event serialization are unchanged.", + "migration": "No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins." }, { "name": "@bsv/btms", @@ -217,21 +217,21 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change.", + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts.", "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract.", + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts.", "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies.", + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts.", "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate." }, { diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 1c4c52d0e..eb0469b70 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -309,7 +309,7 @@ "@bsv/simple": "0.6.1", "@bsv/templates": "1.10.4", "@bsv/authsocket": "2.1.8", - "@bsv/authsocket-client": "2.1.7", + "@bsv/authsocket-client": "2.1.8", "@bsv/message-box-client": "2.6.0", "@bsv/paymail": "2.4.10", "@bsv/402-pay": "0.3.3", diff --git a/packages/messaging/authsocket-client/README.md b/packages/messaging/authsocket-client/README.md index cb045de5e..fb40223ae 100644 --- a/packages/messaging/authsocket-client/README.md +++ b/packages/messaging/authsocket-client/README.md @@ -11,6 +11,14 @@ This package provides a **drop-in client-side solution** for Socket.IO that or any BRC-103-compatible server. - Minimal changes compared to normal `socket.io-client` usage. +## Next release candidate + +Unpublished 2.1.8 refreshes the UMD browser bundle with the integrated SDK. +AuthSocket APIs, signed event JSON and the ESM/CommonJS SDK peer range are +unchanged. The rebuilt UMD bytes differ from published 2.1.7, so distributors +should adopt the new artifact only after publication and retain its license +notices. Current wallet release pins are unaffected. + ## Installation Install the client and its required SDK peer: diff --git a/packages/messaging/authsocket-client/package.json b/packages/messaging/authsocket-client/package.json index aa8ef5fee..867ead9ad 100644 --- a/packages/messaging/authsocket-client/package.json +++ b/packages/messaging/authsocket-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/authsocket-client", - "version": "2.1.7", + "version": "2.1.8", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index f5b7a4477..332ce02b3 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -10,6 +10,11 @@ attention to changes that materially alter behavior or extend functionality. concurrent read capability checks and fair foreground/background ownership. - Separate fixed source/commit latency from marginal row cost and bound upward probes, page records and proof concurrency. +- Reduce redundant storage promise forwarding while retaining authorization + inside exclusive ownership; failures release the next queued operation. +- Share the remote forwarding rejection boundary, skip uncontended priority + searches and avoid repeated queue/checkpoint helper allocations. Custom RPC + throws remain Promise rejections; wire, result and fairness contracts remain. - Check chains before sync writes and preserve returned, serialized and thrown failures without advancing progress. - Repair stale selected/input/broadcast proofs against canonical evidence; fence diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index 808731a34..c11cbdcec 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -342,12 +342,12 @@ export class WalletStorageManager implements sdk.WalletStorage { } } - async runAsWriter(writer: (active: sdk.WalletStorageWriter) => Promise): Promise { - return await this.withAccess(writer) + runAsWriter(writer: (active: sdk.WalletStorageWriter) => Promise): Promise { + return this.withAccess(writer) } - async runAsReader(reader: (active: sdk.WalletStorageReader) => Promise): Promise { - return await this.withAccess(reader, true) + runAsReader(reader: (active: sdk.WalletStorageReader) => Promise): Promise { + return this.withAccess(reader, true) } /** Borrowed activeSync is the legacy explicit reentrancy contract for an already-held exclusive operation. */ @@ -358,12 +358,12 @@ export class WalletStorageManager implements sdk.WalletStorage { return activeSync == null ? await this.withAccess(sync) : await sync(activeSync) } - async runAsStorageProvider(sync: (active: StorageProvider) => Promise): Promise { - return await this.withAccess(async active => { + runAsStorageProvider(sync: (active: StorageProvider) => Promise): Promise { + return this.withAccess(active => { if (!active.isStorageProvider()) { throw new WERR_INVALID_OPERATION('Active "WalletStorageProvider" does not support "StorageProvider" interface.') } - return await sync(active as unknown as StorageProvider) + return sync(active as unknown as StorageProvider) }) } @@ -905,24 +905,21 @@ export class WalletStorageManager implements sdk.WalletStorage { atomicCheckpoint, mode: paged ? 'paged' : 'exclusive', loadRequest: () => this.loadSyncRequest(auth, writer, readerSettings, writerSettings.storageIdentityKey), - prepare: paged - ? (args, chunk) => (writer as StorageProvider).prepareSyncChunk(args, chunk) - : undefined, + prepare: paged ? (args, chunk) => (writer as StorageProvider).prepareSyncChunk(args, chunk) : undefined, commit }, options ) if (paged) { - return await run( - operation => - this.withAccess( - () => { - assertCurrent() - return operation() - }, - false, - true - ) + return await run(operation => + this.withAccess( + () => { + assertCurrent() + return operation() + }, + false, + true + ) ) } return await this.runAsSync(() => { diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts index 430eef124..e13bd0059 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts @@ -88,6 +88,119 @@ describe('WalletStorageManager tests', () => { } }) + test.each(['runAsReader', 'runAsWriter', 'runAsStorageProvider'] as const)( + '%s returns a rejection for synchronous callback errors and releases ownership', + async method => { + for (const { storage } of ctxs) { + const failure = new Error('synchronous callback failure') + const operation = storage[method](() => { + throw failure + }) + expect(operation).toBeInstanceOf(Promise) + await expect(operation).rejects.toBe(failure) + await expect(storage.runAsWriter(async () => 'next writer')).resolves.toBe('next writer') + } + } + ) + + test.each(['runAsReader', 'runAsWriter', 'runAsStorageProvider'] as const)( + '%s holds ownership through asynchronous rejection, then releases the waiting writer', + async method => { + for (const { storage } of ctxs) { + let reject!: (error: Error) => void + let entered!: () => void + const started = new Promise(resolve => { + entered = resolve + }) + const pending = new Promise((_resolve, fail) => { + reject = fail + }) + const operation = storage[method](() => { + entered() + return pending + }) + await started + const next = jest.fn(async () => 'next writer') + const waiting = storage.runAsWriter(next) + await Promise.resolve() + expect(next).not.toHaveBeenCalled() + const failure = new Error('asynchronous callback failure') + const rejected = expect(operation).rejects.toBe(failure) + reject(failure) + await rejected + await expect(waiting).resolves.toBe('next writer') + expect(next).toHaveBeenCalledTimes(1) + } + } + ) + + test('writer authorization failure never dispatches and releases the next writer', async () => { + for (const { storage } of ctxs) { + const failure = new Error('authorization unavailable') + const auth = jest.spyOn(storage, 'getAuth').mockRejectedValueOnce(failure) + const dispatch = jest.spyOn(storage.getActive(), 'createAction') + try { + await expect(storage.createAction({} as any)).rejects.toBe(failure) + expect(auth).toHaveBeenCalledWith(true) + expect(dispatch).not.toHaveBeenCalled() + await expect(storage.runAsWriter(async () => 'next writer')).resolves.toBe('next writer') + } finally { + auth.mockRestore() + dispatch.mockRestore() + } + } + }) + + test('writer authorization is evaluated only after the preceding writer releases ownership', async () => { + const { storage } = ctxs[0] + let release!: () => void + let entered!: () => void + const started = new Promise(resolve => { + entered = resolve + }) + const held = storage.runAsWriter(async () => { + entered() + await new Promise(resolve => { + release = resolve + }) + }) + await started + const auth = jest.spyOn(storage, 'getAuth') + const failure = new Error('provider refused action') + const dispatch = jest.spyOn(storage.getActive(), 'createAction').mockImplementation(() => { + throw failure + }) + try { + const action = storage.createAction({} as any) + const rejected = expect(action).rejects.toBe(failure) + await Promise.resolve() + expect(auth).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + release() + await held + await rejected + expect(auth).toHaveBeenCalledWith(true) + expect(dispatch).toHaveBeenCalledTimes(1) + await expect(storage.runAsWriter(async () => 'next writer')).resolves.toBe('next writer') + } finally { + release() + await held + auth.mockRestore() + dispatch.mockRestore() + } + }) + + test('borrowed sync callback throws remain asynchronous rejections', async () => { + const { storage } = ctxs[0] + const failure = new Error('borrowed callback failed') + const operation = storage.runAsSync(() => { + throw failure + }, storage.getActive()) + expect(operation).toBeInstanceOf(Promise) + await expect(operation).rejects.toBe(failure) + await expect(storage.runAsWriter(async () => 'next writer')).resolves.toBe('next writer') + }) + test('1_runAsReader runAsWriter runAsSync interlock correctly', async () => { const { storage } = await _tu.createSQLiteTestSetup1Wallet({ databaseName: 'syncTest1' diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index 8cf45ec0b..fc14c34ce 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -361,6 +361,11 @@ export abstract class StorageClientBase implements WalletStorageProvider { */ protected abstract rpcCall(method: string, params: unknown[]): Promise + /** Share the asynchronous rejection boundary, including custom transports that throw synchronously. */ + private async forwardRpc(method: string, params: unknown[]): Promise { + return this.rpcCall(method, params) + } + protected nextRequestId(): number { if (!Number.isSafeInteger(this.nextId) || this.nextId < 1) { throw new Error('Wallet storage request identifier space exhausted.') @@ -417,8 +422,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { /** * Called to cleanup resources when no further use of this object will occur. */ - async destroy(): Promise { - return await this.rpcCall('destroy', []) + destroy(): Promise { + return this.forwardRpc('destroy', []) } /** @@ -428,8 +433,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param storageIdentityKey Unique identity key for remote storage if it does not yet exist. * @returns current schema migration identifier */ - async migrate(storageName: string, _storageIdentityKey: string): Promise { - return await this.rpcCall('migrate', [storageName]) + migrate(storageName: string, _storageIdentityKey: string): Promise { + return this.forwardRpc('migrate', [storageName]) } /** @@ -461,8 +466,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args Original wallet `internalizeAction` arguments. * @returns `internalizeAction` results */ - async internalizeAction(auth: AuthId, args: InternalizeActionArgs): Promise { - return await this.rpcCall('internalizeAction', [auth, args]) + internalizeAction(auth: AuthId, args: InternalizeActionArgs): Promise { + return this.forwardRpc('internalizeAction', [auth, args]) } /** @@ -515,47 +520,47 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args `StorageProcessActionArgs` convey completed signed transaction to storage. * @returns `StorageProcessActionResults` supporting final wallet processing to yield `createAction` or `signAction` results. */ - async processAction(auth: AuthId, args: StorageProcessActionArgs): Promise { - return await this.rpcCall('processAction', [auth, args]) + processAction(auth: AuthId, args: StorageProcessActionArgs): Promise { + return this.forwardRpc('processAction', [auth, args]) } - async prepareNoSendExpiry(auth: AuthId, args: ValidCreateActionArgs): Promise { - return await this.rpcCall('prepareNoSendExpiry', [auth, args]) + prepareNoSendExpiry(auth: AuthId, args: ValidCreateActionArgs): Promise { + return this.forwardRpc('prepareNoSendExpiry', [auth, args]) } - async activateNoSendExpiry( + activateNoSendExpiry( auth: AuthId, args: StorageActivateNoSendExpiryArgs ): Promise { - return await this.rpcCall('activateNoSendExpiry', [auth, args]) + return this.forwardRpc('activateNoSendExpiry', [auth, args]) } async armNoSendExpiry(auth: AuthId, args: StorageArmNoSendExpiryArgs): Promise { await this.rpcCall('armNoSendExpiry', [auth, args]) } - async getCapabilities(): Promise { - return await this.rpcCall('getCapabilities', []) + getCapabilities(): Promise { + return this.forwardRpc('getCapabilities', []) } - async beginActionBatch(auth: AuthId, args: BeginActionBatchArgs): Promise { - return await this.rpcCall('beginActionBatch', [auth, args]) + beginActionBatch(auth: AuthId, args: BeginActionBatchArgs): Promise { + return this.forwardRpc('beginActionBatch', [auth, args]) } - async extendActionBatch(auth: AuthId, args: ExtendActionBatchArgs): Promise { - return await this.rpcCall('extendActionBatch', [auth, args]) + extendActionBatch(auth: AuthId, args: ExtendActionBatchArgs): Promise { + return this.forwardRpc('extendActionBatch', [auth, args]) } - async renewActionBatch(auth: AuthId, batchId: string): Promise { - return await this.rpcCall('renewActionBatch', [auth, batchId]) + renewActionBatch(auth: AuthId, batchId: string): Promise { + return this.forwardRpc('renewActionBatch', [auth, batchId]) } - async resumeActionBatch(auth: AuthId, args: ResumeActionBatchArgs): Promise { - return await this.rpcCall('resumeActionBatch', [auth, args]) + resumeActionBatch(auth: AuthId, args: ResumeActionBatchArgs): Promise { + return this.forwardRpc('resumeActionBatch', [auth, args]) } - async prepareActionBatchCommit(auth: AuthId, manifest: ActionBatchManifest): Promise { - return await this.rpcCall('prepareActionBatchCommit', [auth, manifest]) + prepareActionBatchCommit(auth: AuthId, manifest: ActionBatchManifest): Promise { + return this.forwardRpc('prepareActionBatchCommit', [auth, manifest]) } async putActionBatchBlob(auth: AuthId, args: PutActionBatchBlobArgs): Promise { @@ -597,16 +602,16 @@ export abstract class StorageClientBase implements WalletStorageProvider { } } - async commitActionBatch(auth: AuthId, manifest: ActionBatchManifest): Promise { - return await this.rpcCall('commitActionBatch', [auth, manifest]) + commitActionBatch(auth: AuthId, manifest: ActionBatchManifest): Promise { + return this.forwardRpc('commitActionBatch', [auth, manifest]) } - async commitActionBatchByDigest(auth: AuthId, args: CommitActionBatchByDigestArgs): Promise { - return await this.rpcCall('commitActionBatchByDigest', [auth, args]) + commitActionBatchByDigest(auth: AuthId, args: CommitActionBatchByDigestArgs): Promise { + return this.forwardRpc('commitActionBatchByDigest', [auth, args]) } - async abortActionBatch(auth: AuthId, batchId: string): Promise { - return await this.rpcCall('abortActionBatch', [auth, batchId]) + abortActionBatch(auth: AuthId, batchId: string): Promise { + return this.forwardRpc('abortActionBatch', [auth, batchId]) } /** @@ -616,8 +621,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args original wallet `abortAction` args. * @returns `abortAction` result. */ - async abortAction(auth: AuthId, args: AbortActionArgs): Promise { - return await this.rpcCall('abortAction', [auth, args]) + abortAction(auth: AuthId, args: AbortActionArgs): Promise { + return this.forwardRpc('abortAction', [auth, args]) } /** @@ -626,8 +631,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param identityKey of the user. * @returns `TableUser` for the user and whether a new user was created. */ - async findOrInsertUser(identityKey: string): Promise<{ user: TableUser; isNew: boolean }> { - return await this.rpcCall<{ user: TableUser; isNew: boolean }>('findOrInsertUser', [identityKey]) + findOrInsertUser(identityKey: string): Promise<{ user: TableUser; isNew: boolean }> { + return this.forwardRpc<{ user: TableUser; isNew: boolean }>('findOrInsertUser', [identityKey]) } /** Read compact progress only when the provider advertises support. */ @@ -672,9 +677,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param certificate the certificate to insert. * @returns record Id of the inserted `TableCertificate` record. */ - async insertCertificateAuth(auth: AuthId, certificate: TableCertificateX): Promise { - const r = await this.rpcCall('insertCertificateAuth', [auth, certificate]) - return r + insertCertificateAuth(auth: AuthId, certificate: TableCertificateX): Promise { + return this.forwardRpc('insertCertificateAuth', [auth, certificate]) } /** @@ -684,9 +688,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args Validated extension of original wallet `listActions` arguments. * @returns `listActions` results. */ - async listActions(auth: AuthId, vargs: ValidListActionsArgs): Promise { - const r = await this.rpcCall('listActions', [auth, vargs]) - return r + listActions(auth: AuthId, vargs: ValidListActionsArgs): Promise { + return this.forwardRpc('listActions', [auth, vargs]) } /** @@ -696,9 +699,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args Validated extension of original wallet `listOutputs` arguments. * @returns `listOutputs` results. */ - async listOutputs(auth: AuthId, vargs: ValidListOutputsArgs): Promise { - const r = await this.rpcCall('listOutputs', [auth, vargs]) - return r + listOutputs(auth: AuthId, vargs: ValidListOutputsArgs): Promise { + return this.forwardRpc('listOutputs', [auth, vargs]) } /** @@ -708,9 +710,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args Validated extension of original wallet `listCertificates` arguments. * @returns `listCertificates` results. */ - async listCertificates(auth: AuthId, vargs: ValidListCertificatesArgs): Promise { - const r = await this.rpcCall('listCertificates', [auth, vargs]) - return r + listCertificates(auth: AuthId, vargs: ValidListCertificatesArgs): Promise { + return this.forwardRpc('listCertificates', [auth, vargs]) } /** @@ -793,8 +794,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * This must match the `AuthFetch` identity securing the remote conneciton. * @param args original wallet `relinquishCertificate` args. */ - async relinquishCertificate(auth: AuthId, args: RelinquishCertificateArgs): Promise { - return await this.rpcCall('relinquishCertificate', [auth, args]) + relinquishCertificate(auth: AuthId, args: RelinquishCertificateArgs): Promise { + return this.forwardRpc('relinquishCertificate', [auth, args]) } /** @@ -806,8 +807,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * This must match the `AuthFetch` identity securing the remote conneciton. * @param args original wallet `relinquishOutput` args. */ - async relinquishOutput(auth: AuthId, args: RelinquishOutputArgs): Promise { - return await this.rpcCall('relinquishOutput', [auth, args]) + relinquishOutput(auth: AuthId, args: RelinquishOutputArgs): Promise { + return this.forwardRpc('relinquishOutput', [auth, args]) } /** @@ -1034,11 +1035,10 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args proof request and new transaction proof data * @returns results of updates */ - async updateProvenTxReqWithNewProvenTx( + updateProvenTxReqWithNewProvenTx( args: UpdateProvenTxReqWithNewProvenTxArgs ): Promise { - const r = await this.rpcCall('updateProvenTxReqWithNewProvenTx', [args]) - return r + return this.forwardRpc('updateProvenTxReqWithNewProvenTx', [args]) } /** @@ -1050,8 +1050,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * This must match the `AuthFetch` identity securing the remote conneciton. * @param newActiveStorageIdentityKey which must be a currently configured backup storage provider. */ - async setActive(auth: AuthId, newActiveStorageIdentityKey: string): Promise { - return await this.rpcCall('setActive', [auth, newActiveStorageIdentityKey]) + setActive(auth: AuthId, newActiveStorageIdentityKey: string): Promise { + return this.forwardRpc('setActive', [auth, newActiveStorageIdentityKey]) } /** @see {@link validateDate} */ diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.forwarding.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.forwarding.test.ts new file mode 100644 index 000000000..49b15f1e0 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.forwarding.test.ts @@ -0,0 +1,67 @@ +import type { WalletInterface } from '@bsv/sdk' +import { StorageClientBase } from '../StorageClientBase' + +class ForwardingClient extends StorageClientBase { + constructor(private readonly send: (method: string, params: unknown[]) => Promise) { + super({} as WalletInterface, 'https://storage.example.test') + } + + protected rpcCall(method: string, params: unknown[]): Promise { + return this.send(method, params) as Promise + } +} + +const auth = { identityKey: `02${'11'.repeat(32)}`, userId: 7, isActive: true } +const abort = { reference: 'action-reference' } +const cases = [ + { method: 'getCapabilities', params: [], run: (client: ForwardingClient) => client.getCapabilities() }, + { method: 'abortAction', params: [auth, abort], run: (client: ForwardingClient) => client.abortAction(auth, abort) }, + { + method: 'renewActionBatch', + params: [auth, 'batch-reference'], + run: (client: ForwardingClient) => client.renewActionBatch(auth, 'batch-reference') + } +] + +test.each(cases)('$method converts custom synchronous transport errors to rejected promises', async ({ run }) => { + const failure = new Error('synchronous custom transport failure') + const client = new ForwardingClient(() => { + throw failure + }) + const operation = run(client) + expect(operation).toBeInstanceOf(Promise) + await expect(operation).rejects.toBe(failure) +}) + +test.each(cases)('$method preserves asynchronous transport failure identity', async ({ run }) => { + const failure = new Error('asynchronous custom transport failure') + const client = new ForwardingClient(async () => { + throw failure + }) + await expect(run(client)).rejects.toBe(failure) +}) + +test.each(cases)( + '$method waits for transport settlement and retains parameters and result', + async ({ method, params, run }) => { + let finish!: (value: unknown) => void + const pending = new Promise(resolve => { + finish = resolve + }) + const send = jest.fn(() => pending) + const client = new ForwardingClient(send) + const settled = jest.fn() + const operation = run(client).then(value => { + settled() + return value + }) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledTimes(1) + expect(send).toHaveBeenCalledWith(method, params) + const result = { received: true } + finish(result) + await expect(operation).resolves.toBe(result) + expect(settled).toHaveBeenCalledTimes(1) + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.test.ts index fd04e7c77..aeb132dce 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.test.ts @@ -93,3 +93,39 @@ test('ages a background page ahead of newly queued foreground work', async () => now.mockRestore() } }) + +test.each(['foreground', 'background'] as const)('retains FIFO order with only %s waiters', async priority => { + const queue = new StorageAccessQueue() + const unblock = await queue.acquire('exclusive') + const order: number[] = [] + const waiting = Array.from({ length: 12 }, (_, index) => + queue.acquire('exclusive', priority).then(release => { + order.push(index) + release() + }) + ) + unblock() + await Promise.all(waiting) + expect(order).toEqual(Array.from({ length: 12 }, (_, index) => index)) +}) + +test('finds an aged background waiter behind the foreground head', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1000) + try { + const queue = new StorageAccessQueue() + const unblock = await queue.acquire('exclusive') + const order: string[] = [] + const waiting = (['foreground', 'background', 'foreground'] as const).map((priority, index) => + queue.acquire('exclusive', priority).then(release => { + order.push(`${priority} ${index}`) + release() + }) + ) + now.mockReturnValue(2000) + unblock() + await Promise.all(waiting) + expect(order).toEqual(['background 1', 'foreground 0', 'foreground 2']) + } finally { + now.mockRestore() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts index 5fc846dbf..48d39611b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/StorageAccessQueue.ts @@ -25,17 +25,25 @@ export class StorageAccessQueue { }) } + private first(priority: Priority): number { + for (let index = 0; index < this.waiters.length; index++) { + if (this.waiters[index].priority === priority) return index + } + return -1 + } + private nextIndex(): number { - const background = this.waiters.findIndex(waiter => waiter.priority === 'background') + const background = this.first('background') if (background >= 0 && (this.foregroundGrants >= 8 || Date.now() - this.waiters[background].queuedAt >= 1000)) return background - const foreground = this.waiters.findIndex(waiter => waiter.priority === 'foreground') + const foreground = this.first('foreground') return Math.max(foreground, 0) } private pump(): void { while (!this.exclusive && this.waiters.length > 0) { - const index = this.nextIndex() + // Uncontended access needs no priority search or predicate allocation. + const index = this.waiters.length === 1 ? 0 : this.nextIndex() const waiter = this.waiters[index] if (waiter.mode === 'exclusive' ? this.readers > 0 : this.readers >= 8) return this.waiters.splice(index, 1) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts index d205e2dbd..8b3e2528e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts @@ -15,11 +15,12 @@ const entityNames = [ 'provenTxReq' ] +function invalidCheckpoint(): never { + throw new TypeError('Invalid sync checkpoint') +} + /** Validate remote progress and return only the fields permitted to advance a sync. */ export function validateSyncCheckpoint(value: SyncCheckpoint, previous?: Partial): SyncCheckpoint { - const invalid = (): never => { - throw new TypeError('Invalid sync checkpoint') - } if ( value == null || typeof value !== 'object' || @@ -29,25 +30,25 @@ export function validateSyncCheckpoint(value: SyncCheckpoint, previous?: Partial !Array.isArray(value.offsets) || value.offsets.length !== entityNames.length ) - invalid() + invalidCheckpoint() const offsets = entityNames.map((name, index) => { const entry = value.offsets[index] - if (entry?.name !== name || !Number.isSafeInteger(entry.offset) || entry.offset < 0) invalid() + if (entry?.name !== name || !Number.isSafeInteger(entry.offset) || entry.offset < 0) invalidCheckpoint() return { name, offset: entry.offset } }) let since: Date | undefined if (value.since != null) { - if (!(value.since instanceof Date) && typeof value.since !== 'string') invalid() + if (!(value.since instanceof Date) && typeof value.since !== 'string') invalidCheckpoint() since = new Date(value.since) - if (!Number.isFinite(since.getTime())) invalid() + if (!Number.isFinite(since.getTime())) invalidCheckpoint() } - if (previous?.since != null && (since == null || since < previous.since)) invalid() + if (previous?.since != null && (since == null || since < previous.since)) invalidCheckpoint() if ( previous != null && since?.getTime() === previous.since?.getTime() && previous.offsets != null && offsets.some((entry, index) => entry.offset < (previous.offsets?.[index]?.offset ?? 0)) ) - invalid() + invalidCheckpoint() return { syncStateId: value.syncStateId, since, offsets } } From dcf3163132c5221e0acacccb8845f07f63b1a420 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 19:37:32 -0700 Subject: [PATCH 020/127] fix(auth): frame byte views from the copied payload --- docs/reference/package-api-migrations.md | 2 +- governance/mutation-testing/targets.mjs | 2 +- governance/package-release-notes.json | 2 +- .../auth-express-middleware/README.md | 2 ++ .../src/__tests/authMiddlewareHelpers.test.ts | 22 +++++++++++++++++++ .../src/authMiddlewareHelpers.ts | 7 +++--- 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 1e6c5b297..8881ad90d 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -117,7 +117,7 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/auth-express-middleware.md](../packages/middleware/auth-express-middleware.md) - Source: [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) -- Release note: Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. Normalizes zero-length dense byte arrays and Uint8Array/Buffer bodies to the BRC-104 -1 sentinel, matching AuthFetch when express.raw runs before authentication. +- Release note: Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. Normalizes zero-length dense byte arrays and Uint8Array/Buffer bodies to the BRC-104 -1 sentinel, matching AuthFetch when express.raw runs before authentication. Frames typed-array bodies from one copied byte vector, so length metadata and serialized payload cannot disagree. - Migration: Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index a54a5ddc5..976619a39 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -423,7 +423,7 @@ export function buildMutationTargets(repositoryRoot) { manifest: 'packages/middleware/auth-express-middleware/package.json', propertyTest: 'packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts', - mutate: ['src/authMiddlewareHelpers.ts:97-104', 'src/authMiddlewareHelpers.ts:178-216'], + mutate: ['src/authMiddlewareHelpers.ts:97-104', 'src/authMiddlewareHelpers.ts:178-217'], ...jestTarget('jest.config.js', ['/src/__tests/authMiddlewareHelpers*.test.ts']) }, 'payment-replay': { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index aaa7945aa..6a4b70233 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -35,7 +35,7 @@ "name": "@bsv/auth-express-middleware", "publishedVersion": "2.2.5", "releaseType": "minor", - "summary": "Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. Normalizes zero-length dense byte arrays and Uint8Array/Buffer bodies to the BRC-104 -1 sentinel, matching AuthFetch when express.raw runs before authentication.", + "summary": "Adds opt-in bounded raw request collection for BRC-118, authenticates the whole body and exact multipart Content-Type before extraction, restores decoded non-multipart application payloads after authentication, and preserves Express set(object) response-header behavior. Normalizes zero-length dense byte arrays and Uint8Array/Buffer bodies to the BRC-104 -1 sentinel, matching AuthFetch when express.raw runs before authentication. Frames typed-array bodies from one copied byte vector, so length metadata and serialized payload cannot disagree.", "migration": "Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged." }, { diff --git a/packages/middleware/auth-express-middleware/README.md b/packages/middleware/auth-express-middleware/README.md index c03c57a16..69d021ae9 100644 --- a/packages/middleware/auth-express-middleware/README.md +++ b/packages/middleware/auth-express-middleware/README.md @@ -28,6 +28,8 @@ values with BRC-104's `-1` body-length sentinel, matching SDK AuthFetch 2.9.0. This includes `express.raw({ type: 'application/octet-stream' })` mounted before authentication without `captureRawBody`: Express may expose an empty Buffer even when HTTP carries no body. Nonempty byte bodies keep their existing preimages. +Typed-array framing uses the same copied bytes for the length prefix and payload, +so a shadowed view-length property cannot produce an inconsistent frame. For empty binary requests, upgrade the SDK client and auth receiver together; older clients that sign length `0` or receivers that reconstruct length `0` require the paired correction. No application payload or persisted-data migration diff --git a/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.test.ts b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.test.ts index 0c218f6a5..6860e69d9 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.test.ts @@ -189,6 +189,28 @@ describe('auth middleware helpers', () => { expect(reader.pos).toBe(reader.bin.length) }) + it.each([ + ['Uint8Array', new Uint8Array([0, 128, 255]), [0, 128, 255]], + ['empty Uint8Array', new Uint8Array(0), []], + ['Buffer', Buffer.from([0, 128, 255]), [0, 128, 255]], + ['empty Buffer', Buffer.alloc(0), []] + ])( + 'frames copied %s bytes independently of shadowed length metadata', + (_name, body, expected) => { + const bytes = expected as number[] + Object.defineProperty(body, 'length', { value: bytes.length === 0 ? 3 : 0 }) + const writer = new Utils.Writer() + writeBodyToWriter( + { body, headers: { 'content-type': 'application/octet-stream' } } as any, + writer + ) + const reader = new Utils.Reader(writer.toArray()) + expect(reader.readVarIntNum()).toBe(bytes.length === 0 ? -1 : bytes.length) + expect(reader.read(bytes.length)).toEqual(bytes) + expect(reader.pos).toBe(reader.bin.length) + } + ) + it.each([ [undefined, undefined], ['', 'text/plain'], diff --git a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts index 5c8970209..7055be023 100644 --- a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts +++ b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts @@ -186,9 +186,10 @@ export function writeBodyToWriter( } if (body instanceof Uint8Array) { - writer.writeVarIntNum(body.length === 0 ? -1 : body.length) - writer.write(Array.from(body)) - debugLog('[writeBodyToWriter] Body recognized as Uint8Array', { length: body.length }) + const bodyAsArray = Array.from(body) + writer.writeVarIntNum(bodyAsArray.length === 0 ? -1 : bodyAsArray.length) + writer.write(bodyAsArray) + debugLog('[writeBodyToWriter] Body recognized as Uint8Array', { length: bodyAsArray.length }) return } From 754765068d25c7358a03ed102a3959877bcd1544 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 19:54:31 -0700 Subject: [PATCH 021/127] refactor(wallet-toolbox): share storage admission and normalization --- .../src/storage/WalletStorageManager.ts | 166 +++++++----------- .../__test/WalletStorageManager.test.ts | 76 ++++++++ .../src/storage/remoting/StorageClient.ts | 21 +-- .../src/storage/remoting/StorageClientBase.ts | 33 ++-- .../src/storage/remoting/StorageMobile.ts | 21 +-- .../__test/StorageClient.security.test.ts | 11 ++ .../__test/StorageClient.telemetry.test.ts | 39 ++++ .../StorageClientBase.forwarding.test.ts | 36 ++++ .../__tests__/entityValidationHelpers.test.ts | 24 ++- .../remoting/entityValidationHelpers.ts | 16 +- 10 files changed, 283 insertions(+), 160 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index c11cbdcec..b95875a27 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -346,10 +346,25 @@ export class WalletStorageManager implements sdk.WalletStorage { return this.withAccess(writer) } + /** Keep foreground writer authorization inside the acquired ownership boundary. */ + private runAsAuthorizedWriter( + operation: (writer: sdk.WalletStorageWriter, auth: sdk.AuthId) => Promise + ): Promise { + return this.runAsWriter(async writer => operation(writer, await this.getAuth(true))) + } + runAsReader(reader: (active: sdk.WalletStorageReader) => Promise): Promise { return this.withAccess(reader, true) } + /** Preserve the legacy reader contract: obtain caller auth before joining the ownership queue. */ + private async runAsAuthorizedReader( + operation: (reader: sdk.WalletStorageReader, auth: sdk.AuthId) => Promise + ): Promise { + const auth = await this.getAuth() + return this.runAsReader(reader => operation(reader, auth)) + } + /** Borrowed activeSync is the legacy explicit reentrancy contract for an already-held exclusive operation. */ async runAsSync( sync: (active: sdk.WalletStorageSync) => Promise, @@ -427,10 +442,8 @@ export class WalletStorageManager implements sdk.WalletStorage { return this.getActive().getSettings() } - async migrate(storageName: string, storageIdentityKey: string): Promise { - return await this.runAsWriter(async writer => { - return await writer.migrate(storageName, storageIdentityKey) - }) + migrate(storageName: string, storageIdentityKey: string): Promise { + return this.runAsWriter(writer => writer.migrate(storageName, storageIdentityKey)) } async destroy(): Promise { @@ -458,52 +471,34 @@ export class WalletStorageManager implements sdk.WalletStorage { async abortAction(args: AbortActionArgs): Promise { validateAbortActionArgs(args) - return await this.runAsWriter(async writer => { - const auth = await this.getAuth(true) - return await writer.abortAction(auth, args) - }) + return await this.runAsAuthorizedWriter((writer, auth) => writer.abortAction(auth, args)) } - async createAction(vargs: ValidCreateActionArgs): Promise { - return await this.runAsWriter(async writer => { - const auth = await this.getAuth(true) - return await writer.createAction(auth, vargs) - }) + createAction(vargs: ValidCreateActionArgs): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.createAction(auth, vargs)) } async internalizeAction(args: InternalizeActionArgs): Promise { validateInternalizeActionArgs(args) - return await this.runAsWriter(async writer => { - const auth = await this.getAuth(true) - return await writer.internalizeAction(auth, args) - }) + return await this.runAsAuthorizedWriter((writer, auth) => writer.internalizeAction(auth, args)) } async relinquishCertificate(args: RelinquishCertificateArgs): Promise { validateRelinquishCertificateArgs(args) - return await this.runAsWriter(async writer => { - const auth = await this.getAuth(true) - return await writer.relinquishCertificate(auth, args) - }) + return await this.runAsAuthorizedWriter((writer, auth) => writer.relinquishCertificate(auth, args)) } async relinquishOutput(args: RelinquishOutputArgs): Promise { validateRelinquishOutputArgs(args) - return await this.runAsWriter(async writer => { - const auth = await this.getAuth(true) - return await writer.relinquishOutput(auth, args) - }) + return await this.runAsAuthorizedWriter((writer, auth) => writer.relinquishOutput(auth, args)) } - async processAction(args: sdk.StorageProcessActionArgs): Promise { - return await this.runAsWriter(async writer => { - const auth = await this.getAuth(true) - return await writer.processAction(auth, args) - }) + processAction(args: sdk.StorageProcessActionArgs): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.processAction(auth, args)) } - async prepareNoSendExpiry(args: ValidCreateActionArgs): Promise { - return await this.runAsWriter(async writer => { + prepareNoSendExpiry(args: ValidCreateActionArgs): Promise { + return this.runAsWriter(async writer => { if (writer.prepareNoSendExpiry == null) { throw new WERR_INVALID_OPERATION('Active storage does not support BRC-177 noSend expiry') } @@ -511,10 +506,8 @@ export class WalletStorageManager implements sdk.WalletStorage { }) } - async activateNoSendExpiry( - args: sdk.StorageActivateNoSendExpiryArgs - ): Promise { - return await this.runAsWriter(async writer => { + activateNoSendExpiry(args: sdk.StorageActivateNoSendExpiryArgs): Promise { + return this.runAsWriter(async writer => { if (writer.activateNoSendExpiry == null) { throw new WERR_INVALID_OPERATION('Active storage does not support BRC-177 noSend expiry') } @@ -531,24 +524,24 @@ export class WalletStorageManager implements sdk.WalletStorage { }) } - async getCapabilities(): Promise { - return await this.runAsReader(async () => await this.getActive().getCapabilities()) + getCapabilities(): Promise { + return this.runAsReader(() => this.getActive().getCapabilities()) } - async beginActionBatch(args: sdk.BeginActionBatchArgs): Promise { - return await this.runAsWriter(async writer => await writer.beginActionBatch(await this.getAuth(true), args)) + beginActionBatch(args: sdk.BeginActionBatchArgs): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.beginActionBatch(auth, args)) } - async extendActionBatch(args: sdk.ExtendActionBatchArgs): Promise { - return await this.runAsWriter(async writer => await writer.extendActionBatch(await this.getAuth(true), args)) + extendActionBatch(args: sdk.ExtendActionBatchArgs): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.extendActionBatch(auth, args)) } - async renewActionBatch(batchId: string): Promise { - return await this.runAsWriter(async writer => await writer.renewActionBatch(await this.getAuth(true), batchId)) + renewActionBatch(batchId: string): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.renewActionBatch(auth, batchId)) } - async resumeActionBatch(args: sdk.ResumeActionBatchArgs): Promise { - return await this.runAsWriter(async writer => { + resumeActionBatch(args: sdk.ResumeActionBatchArgs): Promise { + return this.runAsWriter(async writer => { if (writer.resumeActionBatch == null) { throw new WERR_NOT_IMPLEMENTED('action batch resume is not available') } @@ -556,18 +549,16 @@ export class WalletStorageManager implements sdk.WalletStorage { }) } - async prepareActionBatchCommit(manifest: sdk.ActionBatchManifest): Promise { - return await this.runAsWriter( - async writer => await writer.prepareActionBatchCommit(await this.getAuth(true), manifest) - ) + prepareActionBatchCommit(manifest: sdk.ActionBatchManifest): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.prepareActionBatchCommit(auth, manifest)) } - async putActionBatchBlob(args: sdk.PutActionBatchBlobArgs): Promise { - return await this.runAsWriter(async writer => await writer.putActionBatchBlob(await this.getAuth(true), args)) + putActionBatchBlob(args: sdk.PutActionBatchBlobArgs): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.putActionBatchBlob(auth, args)) } - async putActionBatchPack(args: sdk.PutActionBatchPackArgs): Promise { - return await this.runAsWriter(async writer => { + putActionBatchPack(args: sdk.PutActionBatchPackArgs): Promise { + return this.runAsWriter(async writer => { if (writer.putActionBatchPack == null) { throw new WERR_NOT_IMPLEMENTED('packed action batch uploads are not available') } @@ -575,12 +566,12 @@ export class WalletStorageManager implements sdk.WalletStorage { }) } - async commitActionBatch(manifest: sdk.ActionBatchManifest): Promise { - return await this.runAsWriter(async writer => await writer.commitActionBatch(await this.getAuth(true), manifest)) + commitActionBatch(manifest: sdk.ActionBatchManifest): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.commitActionBatch(auth, manifest)) } - async commitActionBatchByDigest(args: sdk.CommitActionBatchByDigestArgs): Promise { - return await this.runAsWriter(async writer => { + commitActionBatchByDigest(args: sdk.CommitActionBatchByDigestArgs): Promise { + return this.runAsWriter(async writer => { if (writer.commitActionBatchByDigest == null) { throw new WERR_NOT_IMPLEMENTED('digest-only action batch commit is not available') } @@ -588,63 +579,40 @@ export class WalletStorageManager implements sdk.WalletStorage { }) } - async abortActionBatch(batchId: string): Promise { - return await this.runAsWriter(async writer => await writer.abortActionBatch(await this.getAuth(true), batchId)) + abortActionBatch(batchId: string): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.abortActionBatch(auth, batchId)) } - async insertCertificate(certificate: TableCertificate): Promise { - return await this.runAsWriter(async writer => { - const auth = await this.getAuth(true) - return await writer.insertCertificateAuth(auth, certificate) - }) + insertCertificate(certificate: TableCertificate): Promise { + return this.runAsAuthorizedWriter((writer, auth) => writer.insertCertificateAuth(auth, certificate)) } - async listActions(vargs: ValidListActionsArgs): Promise { - const auth = await this.getAuth() - return await this.runAsReader(async reader => { - return await reader.listActions(auth, vargs) - }) + listActions(vargs: ValidListActionsArgs): Promise { + return this.runAsAuthorizedReader((reader, auth) => reader.listActions(auth, vargs)) } - async listCertificates(args: ValidListCertificatesArgs): Promise { - const auth = await this.getAuth() - return await this.runAsReader(async reader => { - return await reader.listCertificates(auth, args) - }) + listCertificates(args: ValidListCertificatesArgs): Promise { + return this.runAsAuthorizedReader((reader, auth) => reader.listCertificates(auth, args)) } - async listOutputs(vargs: ValidListOutputsArgs): Promise { - const auth = await this.getAuth() - return await this.runAsReader(async reader => { - return await reader.listOutputs(auth, vargs) - }) + listOutputs(vargs: ValidListOutputsArgs): Promise { + return this.runAsAuthorizedReader((reader, auth) => reader.listOutputs(auth, vargs)) } - async findCertificates(args: sdk.FindCertificatesArgs): Promise { - const auth = await this.getAuth() - return await this.runAsReader(async reader => { - return await reader.findCertificatesAuth(auth, args) - }) + findCertificates(args: sdk.FindCertificatesArgs): Promise { + return this.runAsAuthorizedReader((reader, auth) => reader.findCertificatesAuth(auth, args)) } - async findOutputBaskets(args: sdk.FindOutputBasketsArgs): Promise { - const auth = await this.getAuth() - return await this.runAsReader(async reader => { - return await reader.findOutputBasketsAuth(auth, args) - }) + findOutputBaskets(args: sdk.FindOutputBasketsArgs): Promise { + return this.runAsAuthorizedReader((reader, auth) => reader.findOutputBasketsAuth(auth, args)) } - async findOutputs(args: sdk.FindOutputsArgs): Promise { - const auth = await this.getAuth() - return await this.runAsReader(async reader => { - return await reader.findOutputsAuth(auth, args) - }) + findOutputs(args: sdk.FindOutputsArgs): Promise { + return this.runAsAuthorizedReader((reader, auth) => reader.findOutputsAuth(auth, args)) } - async findProvenTxReqs(args: sdk.FindProvenTxReqsArgs): Promise { - return await this.runAsReader(async reader => { - return await reader.findProvenTxReqs(args) - }) + findProvenTxReqs(args: sdk.FindProvenTxReqsArgs): Promise { + return this.runAsReader(reader => reader.findProvenTxReqs(args)) } /** diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts index e13bd0059..772fd3d2b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts @@ -134,6 +134,27 @@ describe('WalletStorageManager tests', () => { } ) + test.each(['migrate', 'getCapabilities', 'findProvenTxReqs'] as const)( + '%s preserves synchronous provider failures as rejected promises and releases ownership', + async method => { + const { storage } = ctxs[0] + const failure = new Error('provider failed synchronously') + const dispatch = jest.spyOn(storage.getActive(), method).mockImplementation(() => { + throw failure + }) + try { + const args = method === 'migrate' ? ['name', 'identity'] : method === 'getCapabilities' ? [] : [{}] + const operation = Reflect.get(storage, method).apply(storage, args) + expect(operation).toBeInstanceOf(Promise) + await expect(operation).rejects.toBe(failure) + expect(dispatch).toHaveBeenCalledTimes(1) + await expect(storage.runAsWriter(async () => 'next writer')).resolves.toBe('next writer') + } finally { + dispatch.mockRestore() + } + } + ) + test('writer authorization failure never dispatches and releases the next writer', async () => { for (const { storage } of ctxs) { const failure = new Error('authorization unavailable') @@ -190,6 +211,61 @@ describe('WalletStorageManager tests', () => { } }) + test('reader authorization still precedes queue admission while provider work waits for ownership', async () => { + const { storage } = ctxs[0] + let release!: () => void + let entered!: () => void + const started = new Promise(resolve => { + entered = resolve + }) + const held = storage.runAsWriter(async () => { + entered() + await new Promise(resolve => { + release = resolve + }) + }) + await started + const auth = jest.spyOn(storage, 'getAuth') + const failure = new Error('reader provider refused request') + const dispatch = jest.spyOn(storage.getActive(), 'findOutputsAuth').mockImplementation(() => { + throw failure + }) + try { + const result = storage.findOutputs({} as any) + const rejected = expect(result).rejects.toBe(failure) + expect(auth).toHaveBeenCalledWith() + await Promise.resolve() + expect(dispatch).not.toHaveBeenCalled() + release() + await held + await rejected + expect(dispatch).toHaveBeenCalledTimes(1) + await expect(storage.runAsWriter(async () => 'next writer')).resolves.toBe('next writer') + } finally { + release() + await held + auth.mockRestore() + dispatch.mockRestore() + } + }) + + test('reader authorization rejection never dispatches provider work', async () => { + const { storage } = ctxs[0] + const failure = new Error('reader authorization unavailable') + const auth = jest.spyOn(storage, 'getAuth').mockRejectedValueOnce(failure) + const dispatch = jest.spyOn(storage.getActive(), 'findOutputsAuth') + try { + const result = storage.findOutputs({} as any) + expect(result).toBeInstanceOf(Promise) + await expect(result).rejects.toBe(failure) + expect(dispatch).not.toHaveBeenCalled() + await expect(storage.runAsWriter(async () => 'next writer')).resolves.toBe('next writer') + } finally { + auth.mockRestore() + dispatch.mockRestore() + } + }) + test('borrowed sync callback throws remain asynchronous rejections', async () => { const { storage } = ctxs[0] const failure = new Error('borrowed callback failed') diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts index 7522559de..408c201e5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts @@ -103,8 +103,8 @@ export class StorageClient extends StorageClientBase { * @param method The WalletStorage method name to call. * @param params The array of parameters to pass to the method in order. */ - protected async rpcCall(method: string, params: unknown[]): Promise { - return await this.traceRpcCall(method, params, async rpcSpan => { + protected rpcCall(method: string, params: unknown[]): Promise { + return this.traceRpcCall(method, params, async rpcSpan => { const loggerState = this.startRpcLogging(method, params) const { logger } = loggerState @@ -131,8 +131,8 @@ export class StorageClient extends StorageClientBase { response = await this.traceRpcStep( 'wallet.storage.http', rpcSpan, - async () => - await this.authenticatedFetch(this.endpointUrl, { + () => + this.authenticatedFetch(this.endpointUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -157,15 +157,10 @@ export class StorageClient extends StorageClientBase { const responseUsesBinary = response.headers.get(BINARY_ENCODING_HEADER) === BINARY_ENCODING if (responseUsesBinary) this.serverSupportsBinary = true - const responseText = await this.traceRpcStep( - 'wallet.storage.response.read', - rpcSpan, - async () => await response.text(), - { - 'http.response.status_code': response.status, - 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json' - } - ) + const responseText = await this.traceRpcStep('wallet.storage.response.read', rpcSpan, () => response.text(), { + 'http.response.status_code': response.status, + 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json' + }) const json = await this.traceRpcStep( 'wallet.storage.response.parse', rpcSpan, diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index fc14c34ce..3394fb366 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -132,10 +132,13 @@ export interface StorageClientOptions { } function hasControlCharacter(value: string): boolean { - return Array.from(value).some(character => { - const point = character.codePointAt(0)! - return point <= 0x1f || (point >= 0x7f && point <= 0x9f) - }) + // All rejected characters are in the first 160 UTF-16 code units; avoid + // allocating a code-point array for every endpoint or storage identifier. + for (let index = 0; index < value.length; index++) { + const point = value.charCodeAt(index) + if (point <= 0x1f || (point >= 0x7f && point <= 0x9f)) return true + } + return false } function isLoopbackStorageHost(hostname: string): boolean { @@ -366,6 +369,10 @@ export abstract class StorageClientBase implements WalletStorageProvider { return this.rpcCall(method, params) } + private async readEntities(method: string, params: unknown[]): Promise { + return validateEntities(await this.rpcCall(method, params)) + } + protected nextRequestId(): number { if (!Number.isSafeInteger(this.nextId) || this.nextId < 1) { throw new Error('Wallet storage request identifier space exhausted.') @@ -746,10 +753,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args `FindOutputBasketsArgs` determines which baskets to retrieve. * @returns array of output baskets matching args. */ - async findOutputBasketsAuth(auth: AuthId, args: FindOutputBasketsArgs): Promise { - const r = await this.rpcCall('findOutputBasketsAuth', [auth, args]) - validateEntities(r) - return r + findOutputBasketsAuth(auth: AuthId, args: FindOutputBasketsArgs): Promise { + return this.readEntities('findOutputBasketsAuth', [auth, args]) } /** @@ -762,10 +767,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args `FindOutputsArgs` determines which outputs to retrieve. * @returns array of outputs matching args. */ - async findOutputsAuth(auth: AuthId, args: FindOutputsArgs): Promise { - const r = await this.rpcCall('findOutputsAuth', [auth, args]) - validateEntities(r) - return r + findOutputsAuth(auth: AuthId, args: FindOutputsArgs): Promise { + return this.readEntities('findOutputsAuth', [auth, args]) } /** @@ -778,10 +781,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args `FindProvenTxReqsArgs` determines which proof requests to retrieve. * @returns array of proof requests matching args. */ - async findProvenTxReqs(args: FindProvenTxReqsArgs): Promise { - const r = await this.rpcCall('findProvenTxReqs', [args]) - validateEntities(r) - return r + findProvenTxReqs(args: FindProvenTxReqsArgs): Promise { + return this.readEntities('findProvenTxReqs', [args]) } /** diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageMobile.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageMobile.ts index 0328cbf8f..ffaa1d7f1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageMobile.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageMobile.ts @@ -41,8 +41,8 @@ export class StorageClient extends StorageClientBase { * @param method The WalletStorage method name to call. * @param params The array of parameters to pass to the method in order. */ - protected async rpcCall(method: string, params: unknown[]): Promise { - return await this.traceRpcCall(method, params, async rpcSpan => { + protected rpcCall(method: string, params: unknown[]): Promise { + return this.traceRpcCall(method, params, async rpcSpan => { const id = this.nextRequestId() const body = { jsonrpc: '2.0', @@ -61,8 +61,8 @@ export class StorageClient extends StorageClientBase { const response = await this.traceRpcStep( 'wallet.storage.http', rpcSpan, - async () => - await this.authenticatedFetch(this.endpointUrl, { + () => + this.authenticatedFetch(this.endpointUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -83,15 +83,10 @@ export class StorageClient extends StorageClientBase { const responseUsesBinary = response.headers.get(BINARY_ENCODING_HEADER) === BINARY_ENCODING if (responseUsesBinary) this.serverSupportsBinary = true - const responseText = await this.traceRpcStep( - 'wallet.storage.response.read', - rpcSpan, - async () => await response.text(), - { - 'http.response.status_code': response.status, - 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json' - } - ) + const responseText = await this.traceRpcStep('wallet.storage.response.read', rpcSpan, () => response.text(), { + 'http.response.status_code': response.status, + 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json' + }) const json = await this.traceRpcStep( 'wallet.storage.response.parse', rpcSpan, diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.security.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.security.test.ts index 361e6ec8c..11899ab58 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.security.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.security.test.ts @@ -22,6 +22,17 @@ describe.each([ expect(() => new Client(wallet, 'http://wallet.localhost:8042')).not.toThrow() }) + test.each([0x00, 0x1f, 0x7f, 0x85, 0x9f])('rejects a control code unit %i anywhere in an endpoint', code => { + expect(() => new Client(wallet, `https://storage.example.com/rpc${String.fromCharCode(code)}suffix`)).toThrow( + 'exact bounded URL' + ) + }) + + test('preserves non-ASCII and supplementary Unicode endpoint paths', () => { + const endpoint = 'https://storage.example.com/é/😀' + expect(new Client(wallet, endpoint).endpointUrl).toBe(endpoint) + }) + test('rejects ambiguous endpoint components', () => { expect(() => new Client(wallet, '/rpc')).toThrow('absolute URL') expect(() => new Client(wallet, 'https://user:pass@storage.example.com/rpc')).toThrow( diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts index f85d5148e..f72040490 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts @@ -77,6 +77,45 @@ describe('StorageClient telemetry', () => { } ) + describe.each([ + ['browser and Node', StorageClient], + ['mobile', StorageMobile] + ])('%s callback failures', (_name, Client) => { + it.each([false, true])('preserves response-read rejection with telemetry enabled=%s', async enabled => { + const events: TelemetryEvent[] = [] + const client = new Client( + {} as WalletInterface, + 'https://storage.example.test/rpc', + enabled ? { telemetry: { sink: { capture: (event: TelemetryEvent) => events.push(event) } } } : {} + ) + const error = new Error('response read failed') + const text = jest.fn(() => { + throw error + }) + Reflect.set(client, 'authClient', { + fetch: jest.fn(async () => ({ + ok: true, + status: 200, + headers: new Headers(AUTHENTICATED_HEADERS), + text + })) + }) + + const result = Reflect.get(client, 'rpcCall').call(client, 'isAvailable', [{ userId: 1 }]) + expect(result).toBeInstanceOf(Promise) + await expect(result).rejects.toBe(error) + expect(text).toHaveBeenCalledTimes(1) + if (enabled) { + expect(events.filter(event => event.spanStatus === 'error').map(event => event.name)).toEqual([ + 'wallet.storage.response.read', + 'wallet.storage.rpc' + ]) + } else { + expect(events).toEqual([]) + } + }) + }) + it('preserves caller logging while reporting remote and protocol failures', async () => { const events: TelemetryEvent[] = [] const client = new StorageClient({} as WalletInterface, 'https://storage.example.test/rpc', { diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.forwarding.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.forwarding.test.ts index 49b15f1e0..f6d0d3b91 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.forwarding.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.forwarding.test.ts @@ -13,7 +13,26 @@ class ForwardingClient extends StorageClientBase { const auth = { identityKey: `02${'11'.repeat(32)}`, userId: 7, isActive: true } const abort = { reference: 'action-reference' } +const findArgs = { partial: {} } +const entityCases = [ + { + method: 'findOutputBasketsAuth', + params: [auth, findArgs], + run: (client: ForwardingClient) => client.findOutputBasketsAuth(auth, findArgs) + }, + { + method: 'findOutputsAuth', + params: [auth, findArgs], + run: (client: ForwardingClient) => client.findOutputsAuth(auth, findArgs) + }, + { + method: 'findProvenTxReqs', + params: [findArgs], + run: (client: ForwardingClient) => client.findProvenTxReqs(findArgs) + } +] const cases = [ + ...entityCases, { method: 'getCapabilities', params: [], run: (client: ForwardingClient) => client.getCapabilities() }, { method: 'abortAction', params: [auth, abort], run: (client: ForwardingClient) => client.abortAction(auth, abort) }, { @@ -65,3 +84,20 @@ test.each(cases)( expect(settled).toHaveBeenCalledTimes(1) } ) + +test.each(entityCases)('$method normalizes dates, nulls and bytes in place', async ({ run }) => { + const record = { + created_at: '2024-01-01T00:00:00.000Z', + updated_at: 0, + optional: null, + bytes: new Uint8Array([0, 128, 255]) + } + const records = [record] + const client = new ForwardingClient(async () => records) + await expect(run(client)).resolves.toBe(records) + expect(records[0]).toBe(record) + expect(record.created_at).toEqual(new Date('2024-01-01T00:00:00.000Z')) + expect(record.updated_at).toEqual(new Date(0)) + expect(record.optional).toBeUndefined() + expect(record.bytes).toEqual([0, 128, 255]) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__tests__/entityValidationHelpers.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__tests__/entityValidationHelpers.test.ts index c773dfb1a..9b9c593e0 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__tests__/entityValidationHelpers.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__tests__/entityValidationHelpers.test.ts @@ -1,9 +1,4 @@ -import { - validateDate, - validateEntities, - validateEntity, - validateSyncChunkEntities -} from '../entityValidationHelpers' +import { validateDate, validateEntities, validateEntity, validateSyncChunkEntities } from '../entityValidationHelpers' import { EntityTimeStamp } from '../../../sdk/types' import { SyncChunk } from '../../../sdk/WalletStorage.interfaces' @@ -199,7 +194,11 @@ describe('entityValidationHelpers', () => { test('validates every entity in a multi-entity array', () => { const arr: TestEntity[] = [ - { created_at: '2024-01-01T00:00:00.000Z' as unknown as Date, updated_at: '2024-01-01T00:00:00.000Z' as unknown as Date, name: null }, + { + created_at: '2024-01-01T00:00:00.000Z' as unknown as Date, + updated_at: '2024-01-01T00:00:00.000Z' as unknown as Date, + name: null + }, makeEntity({ blob: new Uint8Array([1, 2, 3]) }), makeEntity({ blob: Buffer.from([4, 5, 6]) as unknown as Uint8Array }) ] @@ -323,6 +322,15 @@ describe('entityValidationHelpers', () => { certificateFields: [makeEntity()] as never, user: makeEntity() as never } + const originalArrays = new Map() + for (const [name, value] of Object.entries(chunk)) { + if (!Array.isArray(value)) continue + const entities = value as TestEntity[] + originalArrays.set(name, entities) + entities[0].created_at = '2024-01-01T00:00:00.000Z' as unknown as Date + entities[0].updated_at = 0 as unknown as Date + entities[0].optional = null + } const result = validateSyncChunkEntities(chunk) expect(result).toBe(chunk) expect((result.provenTxs as unknown as TestEntity[])[0].blob).toEqual([1, 2]) @@ -345,7 +353,9 @@ describe('entityValidationHelpers', () => { ] for (const k of everyArrayKey) { const arr = result[k] as unknown as TestEntity[] + expect(arr).toBe(originalArrays.get(k)) expect(Array.isArray(arr)).toBe(true) + expect(arr[0].optional).toBeUndefined() expect(arr[0].created_at).toBeInstanceOf(Date) expect(arr[0].updated_at).toBeInstanceOf(Date) } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts index ebe21dff3..d0c67c493 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts @@ -116,18 +116,10 @@ function validateSyncChunkTotals(totals: SyncChunkTotals): void { */ export function validateSyncChunkEntities(r: SyncChunk): SyncChunk { if (r.totals != null) validateSyncChunkTotals(r.totals) - if (r.certificateFields != null) r.certificateFields = validateEntities(r.certificateFields) - if (r.certificates != null) r.certificates = validateEntities(r.certificates) - if (r.commissions != null) r.commissions = validateEntities(r.commissions) - if (r.outputBaskets != null) r.outputBaskets = validateEntities(r.outputBaskets) - if (r.outputTagMaps != null) r.outputTagMaps = validateEntities(r.outputTagMaps) - if (r.outputTags != null) r.outputTags = validateEntities(r.outputTags) - if (r.outputs != null) r.outputs = validateEntities(r.outputs) - if (r.provenTxReqs != null) r.provenTxReqs = validateEntities(r.provenTxReqs) - if (r.provenTxs != null) r.provenTxs = validateEntities(r.provenTxs) - if (r.transactions != null) r.transactions = validateEntities(r.transactions) - if (r.txLabelMaps != null) r.txLabelMaps = validateEntities(r.txLabelMaps) - if (r.txLabels != null) r.txLabels = validateEntities(r.txLabels) + for (const name of syncChunkTotalRecordNames) { + const entities = r[name] + if (entities != null) validateEntities(entities) + } if (r.user != null) r.user = validateEntity(r.user) return r } From 893f63269e75ef1121146ed25280da22a1f34327 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 20:23:07 -0700 Subject: [PATCH 022/127] perf(wallet-toolbox): reduce forwarding and normalization allocations --- docs/reference/package-api-migrations.md | 6 +-- governance/package-release-notes.json | 6 +-- packages/wallet/wallet-toolbox/CHANGELOG.md | 4 ++ .../src/storage/remoting/StorageClientBase.ts | 41 ++++++++++--------- .../__tests__/entityValidationHelpers.test.ts | 28 +++++++++++++ .../remoting/entityValidationHelpers.ts | 30 ++++---------- 6 files changed, 69 insertions(+), 46 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index d16f64240..572f8d512 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -523,7 +523,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. - Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -537,7 +537,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. - Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -549,7 +549,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. - Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 352637477..5569df4ab 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,21 +217,21 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts.", + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts.", + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts.", + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate." }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 332ce02b3..d9ed800a4 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -15,6 +15,10 @@ attention to changes that materially alter behavior or extend functionality. - Share the remote forwarding rejection boundary, skip uncontended priority searches and avoid repeated queue/checkpoint helper allocations. Custom RPC throws remain Promise rejections; wire, result and fairness contracts remain. +- Share reader/writer admission helpers without moving authorization across the + queue boundary. Normalize entity arrays in place using one field list and a + prototype-free property-descriptor map, avoiding intermediate maps/arrays + while preserving date, null, byte and non-enumerable-field behavior. - Check chains before sync writes and preserve returned, serialized and thrown failures without advancing progress. - Repair stale selected/input/broadcast proofs against canonical evidence; fence diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index 3394fb366..b1d33500d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -132,13 +132,10 @@ export interface StorageClientOptions { } function hasControlCharacter(value: string): boolean { - // All rejected characters are in the first 160 UTF-16 code units; avoid - // allocating a code-point array for every endpoint or storage identifier. - for (let index = 0; index < value.length; index++) { - const point = value.charCodeAt(index) - if (point <= 0x1f || (point >= 0x7f && point <= 0x9f)) return true - } - return false + return Array.from(value).some(character => { + const point = character.codePointAt(0)! + return point <= 0x1f || (point >= 0x7f && point <= 0x9f) + }) } function isLoopbackStorageHost(hostname: string): boolean { @@ -365,12 +362,12 @@ export abstract class StorageClientBase implements WalletStorageProvider { protected abstract rpcCall(method: string, params: unknown[]): Promise /** Share the asynchronous rejection boundary, including custom transports that throw synchronously. */ - private async forwardRpc(method: string, params: unknown[]): Promise { - return this.rpcCall(method, params) - } - - private async readEntities(method: string, params: unknown[]): Promise { - return validateEntities(await this.rpcCall(method, params)) + private forwardRpc(method: string, params: unknown[]): Promise { + try { + return Promise.resolve(this.rpcCall(method, params)) + } catch (error) { + return Promise.reject(error) + } } protected nextRequestId(): number { @@ -753,8 +750,10 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args `FindOutputBasketsArgs` determines which baskets to retrieve. * @returns array of output baskets matching args. */ - findOutputBasketsAuth(auth: AuthId, args: FindOutputBasketsArgs): Promise { - return this.readEntities('findOutputBasketsAuth', [auth, args]) + async findOutputBasketsAuth(auth: AuthId, args: FindOutputBasketsArgs): Promise { + const r = await this.rpcCall('findOutputBasketsAuth', [auth, args]) + validateEntities(r) + return r } /** @@ -767,8 +766,10 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args `FindOutputsArgs` determines which outputs to retrieve. * @returns array of outputs matching args. */ - findOutputsAuth(auth: AuthId, args: FindOutputsArgs): Promise { - return this.readEntities('findOutputsAuth', [auth, args]) + async findOutputsAuth(auth: AuthId, args: FindOutputsArgs): Promise { + const r = await this.rpcCall('findOutputsAuth', [auth, args]) + validateEntities(r) + return r } /** @@ -781,8 +782,10 @@ export abstract class StorageClientBase implements WalletStorageProvider { * @param args `FindProvenTxReqsArgs` determines which proof requests to retrieve. * @returns array of proof requests matching args. */ - findProvenTxReqs(args: FindProvenTxReqsArgs): Promise { - return this.readEntities('findProvenTxReqs', [args]) + async findProvenTxReqs(args: FindProvenTxReqsArgs): Promise { + const r = await this.rpcCall('findProvenTxReqs', [args]) + validateEntities(r) + return r } /** diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__tests__/entityValidationHelpers.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__tests__/entityValidationHelpers.test.ts index 9b9c593e0..fb6a316a4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__tests__/entityValidationHelpers.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__tests__/entityValidationHelpers.test.ts @@ -159,6 +159,34 @@ describe('entityValidationHelpers', () => { expect(result.id).toBe(1) }) + test('keeps data-only prototype and constructor keys as own values without changing the prototype', () => { + const entity = Object.assign(makeEntity(), JSON.parse('{"constructor":null}')) + Object.defineProperty(entity, '__proto__', { value: null, writable: true, enumerable: true, configurable: true }) + const prototype = Object.getPrototypeOf(entity) + validateEntity(entity) + expect(Object.getPrototypeOf(entity)).toBe(prototype) + for (const key of ['__proto__', 'constructor']) { + expect(Object.getOwnPropertyDescriptor(entity, key)).toEqual({ + value: undefined, + writable: true, + enumerable: true, + configurable: true + }) + } + }) + + test('preserves batch normalization of a non-enumerable own date field', () => { + const entity = makeEntity() + Object.defineProperty(entity, 'ts', { value: '2024-01-01T00:00:00.000Z', configurable: true }) + validateEntity(entity, ['ts']) + expect(Object.getOwnPropertyDescriptor(entity, 'ts')).toEqual({ + value: new Date('2024-01-01T00:00:00.000Z'), + writable: true, + enumerable: true, + configurable: true + }) + }) + test('only normalizes requested date fields that are own properties', () => { const e = makeEntity() const originalPrototype = Object.getPrototypeOf(e) diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts index d0c67c493..780bf5f6c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts @@ -16,21 +16,8 @@ export function validateDate(date: Date | string | number): Date { return new Date(date) } -function defineOwnValues(target: object, values: ReadonlyMap): void { - Object.defineProperties( - target, - Object.fromEntries( - Array.from(values, ([key, value]) => [ - key, - { - value, - writable: true, - enumerable: true, - configurable: true - } - ]) - ) - ) +function ownValue(value: unknown): PropertyDescriptor { + return { value, writable: true, enumerable: true, configurable: true } } /** @@ -41,24 +28,25 @@ export function validateEntity(entity: T, dateFields? const indexedEntity = entity as T & Record entity.created_at = validateDate(entity.created_at) entity.updated_at = validateDate(entity.updated_at) - const replacements = new Map() + const replacements: PropertyDescriptorMap = Object.create(null) if (dateFields != null) { for (const df of dateFields) { const value = indexedEntity[df] if (Object.hasOwn(entity, df) && value) { - replacements.set(df, validateDate(value as Date | string | number)) + replacements[df] = ownValue(validateDate(value as Date | string | number)) } } } for (const key of Object.keys(entity)) { - const val = replacements.has(key) ? replacements.get(key) : indexedEntity[key] + const replacement = replacements[key] + const val = replacement == null ? indexedEntity[key] : replacement.value if (val === null) { - replacements.set(key, undefined) + replacements[key] = ownValue(undefined) } else if (val instanceof Uint8Array) { - replacements.set(key, Array.from(val)) + replacements[key] = ownValue(Array.from(val)) } } - if (replacements.size > 0) defineOwnValues(entity, replacements) + Object.defineProperties(entity, replacements) return entity } From 83a24fe7c173d87ce4c9da4ae05b1798adce5092 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 20:35:20 -0700 Subject: [PATCH 023/127] perf(wallet-toolbox): document reviewed mobile budget and sync evidence --- docs/guides/wallet-sync-reliability.md | 18 +++++++++++++ packages/wallet/wallet-toolbox/CHANGELOG.md | 3 +++ .../wallet/wallet-toolbox/mobile/README.md | 25 +++++++++++++++++++ .../mobile/platform-budget.json | 2 +- .../remoting/entityValidationHelpers.ts | 6 ++++- 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 19dd7182b..a6ba7c86e 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -224,3 +224,21 @@ plan used 17 queries and about 198 ms; the eight-source cold/prepared paths used 14 queries and about 7.3/5.8 ms. The funding, action-batch and legacy storage-sync benchmark gates pass. Their existing external PXC cohorts require their governed MySQL environment and were not executed by the default local invocation. + +### September 24 review-update measurements + +After integrating the SDK compatibility repair, repeat measurements with official +Node 24.18.0 and the same sequential fixture passed the acceptance gates: + +| Backend | Full-copy ms, exclusive / paged | Foreground p95 ms, exclusive / paged | Paged event-loop p95 ms | +| --- | ---: | ---: | ---: | +| Native Chromium IndexedDB | 3943.6 / 3971.1 | 3938.80 / 24.50 | 1.00 | +| SQLite | 2270.1 / 2238.5 | 2247.25 / 0.17 | 82.23 | +| Authenticated HTTP to SQLite | 20982.5 / 21004.3 | 20901.51 / 0.31 | 195.69 | + +These runs include shared reader/writer admission with unchanged authorization +ordering and direct entity normalization. Native peak heap was 79.8 MB exclusive +and 56.8 MB paged. Inclusive boundary replay and the two-page settled unchanged +copy remain intact. The same timing/sample limitations above apply; HTTP crypto +and serialization still contribute event-loop delay. Subsequent forwarding and +immutable-descriptor allocation refinements preserve the measured queue algorithm. diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index d9ed800a4..837a1d2e3 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -19,6 +19,9 @@ attention to changes that materially alter behavior or extend functionality. queue boundary. Normalize entity arrays in place using one field list and a prototype-free property-descriptor map, avoiding intermediate maps/arrays while preserving date, null, byte and non-enumerable-field behavior. +- Adjust only the reviewed mobile Hermes Brotli ceiling to 1,675,000 bytes, + retaining the other five limits. The mobile README records composition, + upstream comparison and 10.09% measured headroom; build settings are unchanged. - Check chains before sync writes and preserve returned, serialized and thrown failures without advancing progress. - Repair stale selected/input/broadcast proofs against canonical evidence; fence diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 3b7ca2d5b..cc062da48 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -182,6 +182,31 @@ pnpm --filter @bsv/wallet-toolbox-mobile test:mobile The gate installs the packed packages in a clean project, bundles them with Metro, checks the public export and mobile-safe module contracts, validates source maps, compiles the result with Hermes, and enforces compressed and uncompressed size budgets. +### 2.15 candidate size review + +The reviewed Hermes Brotli ceiling increases from 1,520,000 to 1,675,000 bytes +for bounded resumable sync, proof recovery and prepared BRC-118 payment transport. +The other five ceilings remain unchanged to retain their existing growth checks; +this deliberately retains their smaller margins. No production dependency, +minifier configuration, public export, source-map or compression setting changes +accompany this budget adjustment. + +With official Node 24.18.0, the candidate measures: + +| Artifact | Raw bytes | gzip bytes | Brotli bytes | +| -------- | --------: | ---------: | -----------: | +| Metro | 2,377,376 | 609,926 | 463,906 | +| Hermes | 4,619,567 | 1,955,050 | 1,521,524 | + +The new Hermes Brotli ceiling gives 10.09% headroom, rounded to 5,000 bytes. +For comparison, [upstream SDK 2.8.3 validation](https://github.com/bsv-blockchain/ts-stack/actions/runs/35948047970) +measured Metro at 2,360,475 / 602,505 / 458,183 bytes and Hermes at +4,609,755 / 1,931,907 / 1,500,120 bytes. The gate continues to inspect the installed +module graph, reject Node-only modules, validate maps and compile Hermes bytecode. +A matching-input cross-platform check produced identical Hermes bytes on Linux +x86_64 and macOS ARM, including reproduction across independent build directories. +These measurements describe this candidate fixture, not an application-size guarantee. + ## License This package is released under the [Open BSV License Version 6](./LICENSE.txt). diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 6056265da..5c6f2cf65 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -9,7 +9,7 @@ "hermes": { "raw": 4675000, "gzip": 1960000, - "brotli": 1520000 + "brotli": 1675000 } } } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts index 780bf5f6c..65defb2ec 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts @@ -20,6 +20,10 @@ function ownValue(value: unknown): PropertyDescriptor { return { value, writable: true, enumerable: true, configurable: true } } +// Property definition copies descriptor fields; null normalization can share +// this immutable descriptor instead of allocating one for every nullable field. +const undefinedValue = Object.freeze(ownValue(undefined)) + /** * Force uniform behaviour across database engines. * Use to process all individual records with timestamps retrieved from database. @@ -41,7 +45,7 @@ export function validateEntity(entity: T, dateFields? const replacement = replacements[key] const val = replacement == null ? indexedEntity[key] : replacement.value if (val === null) { - replacements[key] = ownValue(undefined) + replacements[key] = undefinedValue } else if (val instanceof Uint8Array) { replacements[key] = ownValue(Array.from(val)) } From 8c5810ab26d993deb0739971a1771be57f5ab471 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 20:42:30 -0700 Subject: [PATCH 024/127] fix(wallet-toolbox): keep RPC forwarding explicitly asynchronous --- docs/reference/package-api-migrations.md | 2 +- governance/package-release-notes.json | 2 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 2 +- packages/wallet/wallet-toolbox/mobile/README.md | 8 +++++--- .../src/storage/remoting/StorageClientBase.ts | 8 ++------ 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 572f8d512..d4288915a 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -48,7 +48,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/overlay-topics` | `1.8.4` | `1.8.5` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | | `@bsv/paymail` | `2.4.10` | `2.4.10` | none | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | | `@bsv/payment-express-middleware` | `2.1.7` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.2` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. | +| `@bsv/sdk` | `2.8.3` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. | | `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | | `@bsv/templates` | `1.10.3` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | | `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 5569df4ab..bfefd4c5b 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -166,7 +166,7 @@ }, { "name": "@bsv/sdk", - "publishedVersion": "2.8.2", + "publishedVersion": "2.8.3", "releaseType": "minor", "summary": "Adds BRC-118 negotiated, byte-preserving multipart payments to AuthFetch with bounded header/body budgets, prepare-before-broadcast validation, one-transaction submission/retry, cancellation and typed permanent recovery outcomes. Preserves nonempty non-multipart signature preimages and fixes BRC-29 recipient child-key derivation. Adds independent Python wire/preimage vectors and composed authenticated HTTP/proxy regression coverage. Preserves the configured BRC100 caller originator during automatic wallet discovery, so the HTTP WalletWire probe satisfies the existing transport caller binding. Binds the default HTTP JSON fetch receiver for browsers while retaining custom HTTP clients. Keeps transport preference, bounded discovery, operation timeout behavior, validation and origin checks unchanged. Restores signed listActions net amounts using their historical int64 wire encoding, matching existing JSON validation, while retaining canonical unsigned counts/lengths and nonnegative individual output values.", "migration": "Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes." diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 837a1d2e3..f377a2474 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -21,7 +21,7 @@ attention to changes that materially alter behavior or extend functionality. while preserving date, null, byte and non-enumerable-field behavior. - Adjust only the reviewed mobile Hermes Brotli ceiling to 1,675,000 bytes, retaining the other five limits. The mobile README records composition, - upstream comparison and 10.09% measured headroom; build settings are unchanged. + upstream comparison and 9.98% measured headroom; build settings are unchanged. - Check chains before sync writes and preserve returned, serialized and thrown failures without advancing progress. - Repair stale selected/input/broadcast proofs against canonical evidence; fence diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index cc062da48..161e16441 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -195,10 +195,12 @@ With official Node 24.18.0, the candidate measures: | Artifact | Raw bytes | gzip bytes | Brotli bytes | | -------- | --------: | ---------: | -----------: | -| Metro | 2,377,376 | 609,926 | 463,906 | -| Hermes | 4,619,567 | 1,955,050 | 1,521,524 | +| Metro | 2,377,388 | 609,908 | 464,042 | +| Hermes | 4,619,850 | 1,955,111 | 1,523,040 | -The new Hermes Brotli ceiling gives 10.09% headroom, rounded to 5,000 bytes. +The approved Hermes Brotli ceiling gives 9.98% headroom after the final async +forwarding refinement. Retaining the explicitly reviewed 1,675,000-byte ceiling +is the rationale for this small difference from the normal 10% margin. For comparison, [upstream SDK 2.8.3 validation](https://github.com/bsv-blockchain/ts-stack/actions/runs/35948047970) measured Metro at 2,360,475 / 602,505 / 458,183 bytes and Hermes at 4,609,755 / 1,931,907 / 1,500,120 bytes. The gate continues to inspect the installed diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index b1d33500d..fc14c34ce 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -362,12 +362,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { protected abstract rpcCall(method: string, params: unknown[]): Promise /** Share the asynchronous rejection boundary, including custom transports that throw synchronously. */ - private forwardRpc(method: string, params: unknown[]): Promise { - try { - return Promise.resolve(this.rpcCall(method, params)) - } catch (error) { - return Promise.reject(error) - } + private async forwardRpc(method: string, params: unknown[]): Promise { + return this.rpcCall(method, params) } protected nextRequestId(): number { From 1a97456718a37e265b623acee481bf81a0e152b5 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 23 Sep 2026 20:51:59 -0700 Subject: [PATCH 025/127] fix(wallet-toolbox): isolate remote entity keys in descriptor maps --- docs/reference/package-api-migrations.md | 6 +++--- governance/package-release-notes.json | 6 +++--- packages/wallet/wallet-toolbox/CHANGELOG.md | 4 ++-- packages/wallet/wallet-toolbox/mobile/README.md | 8 ++++---- .../src/storage/remoting/entityValidationHelpers.ts | 12 ++++++------ 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index d4288915a..816848022 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -523,7 +523,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. - Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -537,7 +537,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. - Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -549,7 +549,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. - Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index bfefd4c5b..1fb98a27e 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,21 +217,21 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.0", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a prototype-free descriptor map for in-place entity normalization without intermediate maps/arrays. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained.", "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate." }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index f377a2474..5bd854d0c 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -17,11 +17,11 @@ attention to changes that materially alter behavior or extend functionality. throws remain Promise rejections; wire, result and fairness contracts remain. - Share reader/writer admission helpers without moving authorization across the queue boundary. Normalize entity arrays in place using one field list and a - prototype-free property-descriptor map, avoiding intermediate maps/arrays + Map of property descriptors, avoiding an intermediate mapping array while preserving date, null, byte and non-enumerable-field behavior. - Adjust only the reviewed mobile Hermes Brotli ceiling to 1,675,000 bytes, retaining the other five limits. The mobile README records composition, - upstream comparison and 9.98% measured headroom; build settings are unchanged. + upstream comparison and 9.97% measured headroom; build settings are unchanged. - Check chains before sync writes and preserve returned, serialized and thrown failures without advancing progress. - Repair stale selected/input/broadcast proofs against canonical evidence; fence diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 161e16441..46e15d99f 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -195,11 +195,11 @@ With official Node 24.18.0, the candidate measures: | Artifact | Raw bytes | gzip bytes | Brotli bytes | | -------- | --------: | ---------: | -----------: | -| Metro | 2,377,388 | 609,908 | 464,042 | -| Hermes | 4,619,850 | 1,955,111 | 1,523,040 | +| Metro | 2,377,408 | 609,909 | 463,868 | +| Hermes | 4,619,927 | 1,955,135 | 1,523,206 | -The approved Hermes Brotli ceiling gives 9.98% headroom after the final async -forwarding refinement. Retaining the explicitly reviewed 1,675,000-byte ceiling +The approved Hermes Brotli ceiling gives 9.97% headroom after the final forwarding +and normalization refinements. Retaining the explicitly reviewed 1,675,000-byte ceiling is the rationale for this small difference from the normal 10% margin. For comparison, [upstream SDK 2.8.3 validation](https://github.com/bsv-blockchain/ts-stack/actions/runs/35948047970) measured Metro at 2,360,475 / 602,505 / 458,183 bytes and Hermes at diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts index 65defb2ec..1e9436427 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/entityValidationHelpers.ts @@ -32,25 +32,25 @@ export function validateEntity(entity: T, dateFields? const indexedEntity = entity as T & Record entity.created_at = validateDate(entity.created_at) entity.updated_at = validateDate(entity.updated_at) - const replacements: PropertyDescriptorMap = Object.create(null) + const replacements = new Map() if (dateFields != null) { for (const df of dateFields) { const value = indexedEntity[df] if (Object.hasOwn(entity, df) && value) { - replacements[df] = ownValue(validateDate(value as Date | string | number)) + replacements.set(df, ownValue(validateDate(value as Date | string | number))) } } } for (const key of Object.keys(entity)) { - const replacement = replacements[key] + const replacement = replacements.get(key) const val = replacement == null ? indexedEntity[key] : replacement.value if (val === null) { - replacements[key] = undefinedValue + replacements.set(key, undefinedValue) } else if (val instanceof Uint8Array) { - replacements[key] = ownValue(Array.from(val)) + replacements.set(key, ownValue(Array.from(val))) } } - Object.defineProperties(entity, replacements) + Object.defineProperties(entity, Object.fromEntries(replacements)) return entity } From 61aaff3c13c32f331e851857dd3bbc03845b51b5 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 14:39:03 -0700 Subject: [PATCH 026/127] Clarify recovery source selection and application data coverage --- docs/guides/wallet-backup-recovery.md | 25 ++++++++++++++++++-- docs/guides/wallet-data-portability.md | 20 ++++++++++++++-- docs/guides/wallet-recovery-agent-brief.md | 13 +++++++++-- docs/guides/wallet-recovery-drill.md | 27 ++++++++++++++++++++-- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/docs/guides/wallet-backup-recovery.md b/docs/guides/wallet-backup-recovery.md index 0d89d0a2d..00598d7ef 100644 --- a/docs/guides/wallet-backup-recovery.md +++ b/docs/guides/wallet-backup-recovery.md @@ -3,8 +3,8 @@ id: wallet-backup-recovery title: 'Wallet Backup and Recovery' kind: guide version: '1.0.0' -last_updated: '2026-09-24' -last_verified: '2026-09-24' +last_updated: '2026-09-29' +last_verified: '2026-09-29' review_cadence_days: 30 status: stable tags: [wallet, backup, recovery, brc100, brc38, brc39] @@ -121,12 +121,33 @@ records. Storage operators should also use the ## Make recovery understandable in the product +For device-based backups, expose the available source devices and retained +generations, their recovery points, and whether each initial copy completed. +A newly registered empty device can have the newest timestamp. Do not treat +that timestamp, a matching identity or a zero balance as proof that it is the +right recovery source. Keep older known-good copies reachable, and provide a +supported way to select one when automatic recovery cannot establish coverage. +If the backup protocol combines histories from several devices, qualify that +replay and its conflict rules; do not improvise a database merge in the UI. + +Complete source selection and validate the recovered inventory before starting +the new installation's backup writers. Test two successive reinstalls, including +a newer empty or incomplete device alongside an older complete backup. A single +successful reinstall does not establish this behavior. These device/generation +controls belong to the wallet host's backup system; the BRC-39 file helpers do +not discover remote backups or choose a source device. + Present separate statuses for key recovery and wallet-data backup. Show the selected profile/network, last completed export or backup time, last successful restore test, and any records created since the recovery point. Do not mark a wallet “fully backed up” merely because a seed was displayed, a download was started, or a remote replica is reachable. +After a receive, show whether its transaction and derivation records are covered +by an acknowledged retained recovery point. An on-chain confirmation alone does +not establish that those records survived elsewhere. Keep pending or failed +backup status visible before users uninstall or replace a device. + Suggested wording to adapt to the product's verified behavior: > **Keep both parts.** Your recovery key restores control of this wallet. Your diff --git a/docs/guides/wallet-data-portability.md b/docs/guides/wallet-data-portability.md index a99b57dc3..4ae3e22a7 100644 --- a/docs/guides/wallet-data-portability.md +++ b/docs/guides/wallet-data-portability.md @@ -3,8 +3,8 @@ id: wallet-data-portability title: 'BRC-38/39 Wallet Data Portability' kind: guide version: '1.0.0' -last_updated: '2026-09-24' -last_verified: '2026-09-24' +last_updated: '2026-09-29' +last_verified: '2026-09-29' review_cadence_days: 30 status: stable tags: [wallet, backup, interoperability, brc38, brc39] @@ -50,6 +50,22 @@ profiles, storage-global monitor events, or product data held outside these tables. Inventory contacts, permissions, external files and custom signing dependencies in your product before describing its backup coverage. +This exclusion also applies to application-owned tables in the **same database**: +reports, sessions and other custom tables are not discovered or copied by the +portable helpers. Preserving an output does not preserve its application meaning +when that meaning exists only in a custom table. Use standard baskets, tags, +labels and their relationships where their semantics fit; otherwise provide a +separately versioned application export/import and test its links to restored +outpoints. Distinguish durable user content from disposable sessions, caches and +credentials, which must not be copied indiscriminately. A successful wallet +import must disclose any missing application context. + +For an older source wallet, check its installed exports and schema before +planning a drill. If the required helpers are absent, preserve the original and +qualify an isolated copy through the supported migration/export path. Record +both versions; importing a file on the candidate does not prove that an older +wallet can produce that file unchanged. + The current helpers read multiple tables and materialize the complete document and encrypted file in memory. They do not take a database-wide snapshot, provide streaming archive I/O, expose an archive progress/cancellation API, or diff --git a/docs/guides/wallet-recovery-agent-brief.md b/docs/guides/wallet-recovery-agent-brief.md index f8368ad9d..123326658 100644 --- a/docs/guides/wallet-recovery-agent-brief.md +++ b/docs/guides/wallet-recovery-agent-brief.md @@ -3,8 +3,8 @@ id: wallet-recovery-agent-brief title: 'Wallet Recovery Implementation Brief for AI Agents' kind: guide version: '1.0.0' -last_updated: '2026-09-24' -last_verified: '2026-09-24' +last_updated: '2026-09-29' +last_verified: '2026-09-29' review_cadence_days: 30 status: stable tags: [wallet, recovery, backup, agents, implementation] @@ -57,6 +57,12 @@ Provide encrypted export for the selected profile with a recoverable passphrase, confirmed file-save completion, freshness information and clear exclusions. Preserve library format/KDF defaults and enforce tested resource limits. Keep key recovery and wallet-data backup as separate statuses and instructions. +For remote device backups, expose retained devices/generations and completion +evidence. Do not select a newer empty device solely by timestamp or begin its +backup writes before recovery source selection and validation finish. Preserve +access to older complete copies and test two successive reinstalls. Inventory +custom app tables even when they share the wallet database; define their separate +recovery contract and disclose missing context instead of claiming full export. For import, recover the expected identity independently, decrypt/validate for preview, compare identity and network, then confirm target and explicit mode. @@ -72,6 +78,9 @@ resource limits and clean-device/provider-loss recovery. Verify transaction and derivation records, relationships, tombstones and binary data; balance alone is insufficient. Exercise restart and a controlled synthetic/testnet spend. Test each claimed runtime and cross-wallet direction with exact versions. +Include a restored synthetic BRC-29 output: sign a noSend spend, verify its +unlocking script locally against the independently retained source output, then +abort the isolated test action and verify cleanup without broadcasting. Deliver implementation, user instructions, recovery inventory, acceptance evidence and explicit unresolved limits. Distinguish implemented, locally diff --git a/docs/guides/wallet-recovery-drill.md b/docs/guides/wallet-recovery-drill.md index fd5507c3c..52ab070b1 100644 --- a/docs/guides/wallet-recovery-drill.md +++ b/docs/guides/wallet-recovery-drill.md @@ -3,8 +3,8 @@ id: wallet-recovery-drill title: 'Wallet Recovery Drill and Acceptance Checklist' kind: guide version: '1.0.0' -last_updated: '2026-09-24' -last_verified: '2026-09-24' +last_updated: '2026-09-29' +last_verified: '2026-09-29' review_cadence_days: 30 status: stable tags: [wallet, recovery, backup, testing, interoperability] @@ -101,6 +101,9 @@ which product data is excluded and how it is restored separately. | Concurrent source writes | Export uses the documented consistency procedure; no claim that sequential reads form a database snapshot | | Low-memory device and large file | Enforced product limits, measured peak memory/time and usable failure/progress UI; no weakened cryptography | | Browser eviction, app uninstall, device loss | Recovery succeeds through independent copies, or the exact unsupported case is clearly disclosed | +| Two reinstalls; newest device empty or incomplete | Older complete records remain recoverable; source selection and validation finish before the new device starts backup writes | +| Records split across device histories | The host's supported replay preserves the expected union and conflict semantics, or clearly reports the unsupported recovery path | +| Custom tables beside wallet tables | Standard wallet records recover; excluded application context is disclosed and its separate restoration is tested where supported | | Vendor A export → vendor B import | Same identity/network and compatible row semantics; unsupported product extensions disclosed; test the reverse direction when claimed | | Key/share or passphrase dependency unavailable | Document the supported combinations and actual failure behavior; no circular recovery assumption | @@ -111,6 +114,24 @@ loss. Keep monitor/background jobs disabled until their side effects and replay state have been reviewed. Replicas, backups and service health are separate evidence. +### Prove restored derivation without broadcasting + +For a synthetic BRC-29 fixture, retain the expected source outpoint, locking +script, sender identity and derivation metadata independently of the archive. +After import, use the recovered wallet to prepare and sign a spend with +`noSend: true`, then verify the unlocking script against that original output +locally. A matching balance, identity or returned txid alone does not prove that +the output's derivation metadata survived. Record the actual no-send capability +and exact package versions; do not assume an older wallet exposes current APIs. + +Keep the restored copy's monitor, network broadcasting and provider writes +disabled through tested host controls. Release the test action with the +supported `abortAction` path and verify the isolated target's inputs are +available again. This rehearsal uses synthetic data; it is not permission to +sign against a user's live funds or proof that a physical wallet reinstall +succeeded. Product-owned context and custom signing modules need their own +acceptance evidence even when this signature check passes. + ## Diagnose failures without destroying evidence | Symptom | Safe next step | @@ -137,11 +158,13 @@ Runtime / device / network / backend / schema version: Synthetic fixture ID / profile count / data classes covered: Key recovery method / unavailable factors tested (no secret values): Data recovery method / consistent recovery point / archive digest: +Backup source device / generation / completion evidence / alternatives retained: Product data included / excluded / separate restoration method: Source device/provider dependencies removed for the drill: Restore or merge target and authority decision: Expected vs actual inventory / relationships / binary-data verification: Identity / read / controlled spend / restart / retry outcomes: +BRC-29 no-send signature verification / action cleanup / broadcast isolation: Negative, resource-limit and cross-wallet scenarios with results: Observed data-loss interval / restore duration vs declared objectives: Unresolved gaps / user-visible limitations / remediation owner: From fe9978d60590ad25088f9cfc8a01cc8435cb80f1 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 14:39:03 -0700 Subject: [PATCH 027/127] Refresh compatible audited dependencies for PR 569 --- pnpm-lock.yaml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38081f58a..0ba515ec3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -745,7 +745,7 @@ importers: version: 4.1.0 joi: specifier: ^18.2.5 - version: 18.2.5 + version: 18.2.6 devDependencies: '@bsv/sdk': specifier: workspace:^ @@ -6114,8 +6114,8 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ip-address@10.3.1: - resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -6453,8 +6453,8 @@ packages: node-notifier: optional: true - joi@18.2.5: - resolution: {integrity: sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ==} + joi@18.2.6: + resolution: {integrity: sha512-8MD5jy4xIcTQi/pHbc10auxclVoJS3pPNiLaTNfW0eh90GbOPU+Y2AjDQnuQsUcHUGNJxqTMmAW6Ho895RaKPA==} engines: {node: '>= 20'} js-md4@0.3.2: @@ -7586,8 +7586,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.8: - resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} @@ -8370,8 +8370,8 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@6.28.0: - resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} engines: {node: '>=18.17'} unicode-canonical-property-names-ecmascript@2.0.1: @@ -12752,7 +12752,7 @@ snapshots: dependencies: debug: 4.4.3 express: 5.2.1 - ip-address: 10.3.1 + ip-address: 10.7.2 transitivePeerDependencies: - supports-color @@ -13226,7 +13226,7 @@ snapshots: dependencies: loose-envify: 1.4.0 - ip-address@10.3.1: {} + ip-address@10.7.2: {} ipaddr.js@1.9.1: {} @@ -13772,7 +13772,7 @@ snapshots: - supports-color - ts-node - joi@18.2.5: + joi@18.2.6: dependencies: '@hapi/address': 5.1.1 '@hapi/formula': 3.0.2 @@ -14899,7 +14899,7 @@ snapshots: semver: 7.8.5 tar: 7.5.22 tinyglobby: 0.2.17 - undici: 6.28.0 + undici: 6.28.1 which: 6.0.1 optional: true @@ -15153,7 +15153,7 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.8 + react-is-19: react-is@19.3.0 pretty-ms@9.3.0: dependencies: @@ -15265,7 +15265,7 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.8: {} + react-is@19.3.0: {} react-refresh@0.14.2: {} @@ -16223,7 +16223,7 @@ snapshots: undici-types@8.3.0: {} - undici@6.28.0: + undici@6.28.1: optional: true unicode-canonical-property-names-ecmascript@2.0.1: {} From 0355ddba693afbca872e381b88189b9c750acde2 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 15:20:05 -0700 Subject: [PATCH 028/127] fix: preserve async contracts in analyzer follow-up --- docs/reference/package-api-migrations.md | 2 +- governance/package-release-notes.json | 2 +- .../test/brc118-browser.mjs | 5 +- .../auth/clients/__tests/paymentFixtures.ts | 37 +++++---- packages/wallet/wallet-toolbox/CHANGELOG.md | 3 + .../wallet-toolbox/benchmarks/sync-fixture.ts | 9 ++- .../client/test/sync-browser.ts | 3 +- .../src/storage/StorageProvider.ts | 13 ++-- .../src/storage/WalletStorageManager.ts | 14 ++-- .../src/storage/methods/proofWork.test.ts | 28 ++++++- .../src/storage/methods/proofWork.ts | 18 ++++- .../src/storage/methods/repairBeefProofs.ts | 3 +- scripts/check-template-consumers.mjs | 78 +++++++++++-------- 13 files changed, 140 insertions(+), 75 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 789682b72..cde2db74a 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -523,7 +523,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Makes sequential storage and proof iteration explicit while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. - Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index a7e98e13c..a63123231 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,7 +217,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions.", + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Makes sequential storage and proof iteration explicit while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts.", "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records." }, { diff --git a/packages/middleware/payment-express-middleware/test/brc118-browser.mjs b/packages/middleware/payment-express-middleware/test/brc118-browser.mjs index fa0b41cc2..f35ba8dd1 100644 --- a/packages/middleware/payment-express-middleware/test/brc118-browser.mjs +++ b/packages/middleware/payment-express-middleware/test/brc118-browser.mjs @@ -133,7 +133,8 @@ try { }, { contentType: 'text/plain', body: [], ancestorBytes: 0, multipart: false } ] - for (const item of cases) { + // Reuse the page only after the preceding wallet scenario has settled. + for await (const item of cases) { const target = await receiver(item) const result = await page.evaluate(async args => await globalThis.pay(args), { ...item, @@ -181,7 +182,7 @@ try { assert.equal(manifest.version, '2.8.0') legacyBundle = await build({ ...bundleOptions, alias: { '@bsv/sdk': legacyModule } }) await page.addScriptTag({ url: `${pageOrigin}/legacy.js`, type: 'module' }) - for (const multipart of [true, false]) { + for await (const multipart of [true, false]) { const target = await receiver({ multipart }) const item = { origin: target.origin, diff --git a/packages/sdk/src/auth/clients/__tests/paymentFixtures.ts b/packages/sdk/src/auth/clients/__tests/paymentFixtures.ts index 09e79a1c7..0e5b7941e 100644 --- a/packages/sdk/src/auth/clients/__tests/paymentFixtures.ts +++ b/packages/sdk/src/auth/clients/__tests/paymentFixtures.ts @@ -4,24 +4,29 @@ import { toBase64 } from '../../../primitives/utils.js' import type { CreateActionArgs, CreateActionResult } from '../../../wallet/Wallet.interfaces.js' /** Real Atomic BEEF keeps retry tests sensitive to the wire validation boundary. */ -export async function paymentActionResult(args: CreateActionArgs): Promise { - if (args.options?.sendWith !== undefined) - return { - sendWithResults: args.options.sendWith.map(txid => ({ txid, status: 'unproven' as const })) +export function paymentActionResult(args: CreateActionArgs): Promise { + // Execute immediately but turn construction errors into wallet-style Promise rejections. + return new Promise(resolve => { + if (args.options?.sendWith !== undefined) { + resolve({ + sendWithResults: args.options.sendWith.map(txid => ({ txid, status: 'unproven' as const })) + }) + return } - const source = new Transaction() - source.addOutput({ satoshis: 1000, lockingScript: Script.fromASM('OP_TRUE') }) - const tx = new Transaction() - tx.addInput({ - sourceTransaction: source, - sourceOutputIndex: 0, - unlockingScript: Script.fromASM('OP_TRUE') + const source = new Transaction() + source.addOutput({ satoshis: 1000, lockingScript: Script.fromASM('OP_TRUE') }) + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + tx.addOutput({ + satoshis: args.outputs![0].satoshis, + lockingScript: Script.fromHex(args.outputs![0].lockingScript) + }) + resolve({ txid: tx.id('hex'), tx: tx.toAtomicBEEF() }) }) - tx.addOutput({ - satoshis: args.outputs![0].satoshis, - lockingScript: Script.fromHex(args.outputs![0].lockingScript) - }) - return { txid: tx.id('hex'), tx: tx.toAtomicBEEF() } } const legacy = new Transaction() diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 5d6247ea5..895543903 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -28,6 +28,9 @@ attention to changes that materially alter behavior or extend functionality. monitor updates against primary replacement and concurrent proof corrections. - Keep proof changes and prepared-BEEF invalidation atomic, and retain safe custom provider behavior. No persisted-schema migration is required. +- Make sequential storage and proof iteration explicit while preserving the + eight-worker proof limit, failure draining, deterministic transaction order + and Promise rejection contracts. - Exercise large copies, tombstones, restart/lost acknowledgements and foreground latency on SQLite, authenticated HTTP and native Chromium IndexedDB. Inclusive timestamp boundary traffic remains a snapshot/high-water follow-up. diff --git a/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts b/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts index c425308cb..e7dbd1066 100644 --- a/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts +++ b/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts @@ -7,6 +7,10 @@ export const proofBytes = 32 * 1024 export const pageBytes = 256 * 1024 export const fixedReadMs = 15 +function* fixtureIndexes(count: number) { + for (let index = 0; index < count; index++) yield index +} + export async function seedSyncBenchmark( storage: Pick< StorageProvider, @@ -17,7 +21,8 @@ export async function seedSyncBenchmark( const { user } = await storage.findOrInsertUser(identityKey) const timestamp = new Date(1_700_000_000_000) await storage.transaction(async trx => { - for (let i = 0; i < labels; i++) + // Seed in order on one transaction without queuing thousands of pending writes. + for await (const i of fixtureIndexes(labels)) await storage.insertTxLabel( { txLabelId: 0, @@ -29,7 +34,7 @@ export async function seedSyncBenchmark( }, trx ) - for (let i = 0; i < proofCount; i++) { + for await (const i of fixtureIndexes(proofCount)) { const tx = new Transaction() tx.addOutput({ satoshis: i + 1, lockingScript: Script.fromASM(`OP_FALSE OP_RETURN ${'01'.repeat(proofBytes)}`) }) const txid = tx.id('hex') diff --git a/packages/wallet/wallet-toolbox/client/test/sync-browser.ts b/packages/wallet/wallet-toolbox/client/test/sync-browser.ts index e7ed53a18..fef4d0ac5 100644 --- a/packages/wallet/wallet-toolbox/client/test/sync-browser.ts +++ b/packages/wallet/wallet-toolbox/client/test/sync-browser.ts @@ -46,7 +46,8 @@ async function benchmark() { return chunk } try { - for (const mode of ['exclusive', 'paged'] as const) { + // Modes share a source fixture and must be measured without competing workloads. + for await (const mode of ['exclusive', 'paged'] as const) { const destination = await open() const capabilities = destination.getCapabilities.bind(destination) if (mode === 'exclusive') diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index 35131b7aa..200d50ba8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -1210,12 +1210,8 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal } /** Custom providers may opt into atomic canonical-proof repair; default is no mutation. */ - async compareAndSetProvenTxProof( - _expected: TableProvenTx, - _replacement: TableProvenTx, - _trx?: TrxToken - ): Promise { - return false + compareAndSetProvenTxProof(_expected: TableProvenTx, _replacement: TableProvenTx, _trx?: TrxToken): Promise { + return Promise.resolve(false) } async attemptToPostReqsToNetwork( @@ -1466,7 +1462,10 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal // avoids a transaction startup/commit for every record (especially costly // in IndexedDB) and makes the page checkpoint atomic with its data changes. return await this.transaction(async trx => { - for (const previous of [...expected.values()].sort((left, right) => left.txid.localeCompare(right.txid))) { + // Serial sorted reads retain deterministic lock ordering on the shared transaction. + for await (const [, previous] of [...expected.values()] + .sort((left, right) => left.txid.localeCompare(right.txid)) + .entries()) { const current = verifyOneOrNone(await this.findProvenTxs({ partial: { txid: previous.txid }, trx })) if (current == null || !sameSyncProof(current, previous)) { throw new WERR_INVALID_OPERATION('Proof changed during sync preparation; resume from the durable checkpoint.') diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index b95875a27..c70883710 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -195,7 +195,8 @@ export class WalletStorageManager implements sdk.WalletStorage { private async preflightManagedNetworks(peer?: TableSettings): Promise { let reference = peer - for (const store of this._stores) { + // The first available store establishes the network for each later store. + for await (const store of this._stores) { store.settings ??= await store.storage.makeAvailable() if (reference != null) assertSyncNetwork(reference, store.settings) reference ??= store.settings @@ -689,8 +690,8 @@ export class WalletStorageManager implements sdk.WalletStorage { this.assertProofDestination(storage, generation) if (ptxs.length === 0) return await active.transaction(async trx => { - for (let index = 0; index < ptxs.length; index++) { - const ptx = ptxs[index] + // Keep proof writes and their result log in source order within this transaction. + for await (const [index, ptx] of ptxs.entries()) { const { result: proof, replacement } = prepared[index] result.log += proof.log if (replacement !== undefined && proof.updated !== undefined) { @@ -722,10 +723,9 @@ export class WalletStorageManager implements sdk.WalletStorage { created_at: new Date(ptx.created_at), updated_at: new Date(ptx.updated_at) } - const { storage, generation } = await this.runAsStorageProvider(async storage => ({ - storage, - generation: this.generation - })) + const { storage, generation } = await this.runAsStorageProvider(storage => + Promise.resolve({ storage, generation: this.generation }) + ) const { result, replacement } = await this.prepareReproof(storage, ptx) await this.runAsStorageProvider(async active => { this.assertProofDestination(storage, generation) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.test.ts b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.test.ts index 20657cb32..a22916018 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.test.ts @@ -47,7 +47,15 @@ test('bounds proof concurrency and drains already-started I/O before rejecting', }) test('deduplicates and bounds large proof lookups before reaching SQL bindings', async () => { - const findProvenTxs = jest.fn(async () => []) + let active = 0 + let peak = 0 + const findProvenTxs = jest.fn(async () => { + active++ + peak = Math.max(peak, active) + await Promise.resolve() + active-- + return [] + }) const txids = Array.from({ length: 1201 }, (_, i) => i.toString(16).padStart(64, '0')) await expect( findProofRecords({ findProvenTxs } as unknown as StorageProvider, [...txids, ...txids]) @@ -56,4 +64,22 @@ test('deduplicates and bounds large proof lookups before reaching SQL bindings', const requests = findProvenTxs.mock.calls as unknown as [{ txids: string[] }][] expect(requests.every(([args]) => args.txids.length <= 250)).toBe(true) expect(requests.flatMap(([args]) => args.txids)).toEqual(txids) + expect(peak).toBe(1) + expect(active).toBe(0) +}) + +test('keeps proof results in input order across multiple bounded worker batches', async () => { + let active = 0 + let peak = 0 + const items = Array.from({ length: 21 }, (_, index) => index) + const results = await mapProofWork(items, async index => { + active++ + peak = Math.max(peak, active) + await new Promise(resolve => setTimeout(resolve, 8 - (index % 8))) + active-- + return index * 2 + }) + expect(results).toEqual(items.map(index => index * 2)) + expect(peak).toBe(8) + expect(active).toBe(0) }) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts index 500794d09..04e178f37 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts @@ -2,6 +2,10 @@ import type { StorageProvider } from '../StorageProvider' import type { TrxToken } from '../../sdk/WalletStorage.interfaces' import type { TableProvenTx } from '../schema/tables' +function* proofBatches(txids: string[]) { + for (let offset = 0; offset < txids.length; offset += 250) yield txids.slice(offset, offset + 250) +} + /** Bound SQL parameters even for legacy callers supplying a large proof page. */ export async function findProofRecords( storage: StorageProvider, @@ -10,8 +14,9 @@ export async function findProofRecords( ): Promise { const unique = [...new Set(txids)] const records: TableProvenTx[] = [] - for (let offset = 0; offset < unique.length; offset += 250) { - records.push(...(await storage.findProvenTxs({ partial: {}, txids: unique.slice(offset, offset + 250), trx }))) + // Consume one SQL batch at a time; callers may share a transaction connection. + for await (const batch of proofBatches(unique)) { + records.push(...(await storage.findProvenTxs({ partial: {}, txids: batch, trx }))) } return records } @@ -21,9 +26,14 @@ export async function mapProofWork(items: T[], work: (item: T) => Promise< const results: R[] = [] let next = 0 let failed = false + function* pendingIndexes() { + while (!failed && next < items.length) yield next++ + } const workers = Array.from({ length: Math.min(items.length, 8) }, async () => { - while (!failed && next < items.length) { - const index = next++ + // Each worker consumes serially; all eight share the same admission cursor. + for await (const index of pendingIndexes()) { + // A peer may fail while the iterator hands this worker its next index. + if (failed) break try { results[index] = await work(items[index]) } catch (error) { diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts b/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts index 72b9d34d2..95272256e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts @@ -86,7 +86,8 @@ export async function repairBeefProofs(storage: StorageProvider, beef: Beef, trx if (replacements.some(result => result.expected != null)) { await storage.transaction(async trx => { let changed = false - for (const { proof, expected } of replacements) { + // Preserve transaction write order and finish each compare-and-set before the next. + for await (const { proof, expected } of replacements) { if (expected != null) changed = (await storage.compareAndSetProvenTxProof(expected, proof, trx)) || changed } const extension = storage as StorageProvider & { invalidatePreparedBeefs?: (trx?: TrxToken) => Promise } diff --git a/scripts/check-template-consumers.mjs b/scripts/check-template-consumers.mjs index 3790d6ea0..665b36aec 100644 --- a/scripts/check-template-consumers.mjs +++ b/scripts/check-template-consumers.mjs @@ -157,6 +157,15 @@ check.equal(MandalaToken, templates.MandalaToken) ` } +async function completeConsumers(pending) { + // Drain every started child before the caller removes their temporary directories. + const settled = await Promise.allSettled(pending) + return settled.map(result => { + if (result.status === 'rejected') throw result.reason + return result.value + }) +} + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'template-consumers-')) try { const templates = await pack(path.join(root, 'packages/helpers/ts-templates'), temporary) @@ -172,39 +181,44 @@ try { version: '2.8.0' } ] - const results = [] - for (const profile of profiles) { - const cwd = path.join(temporary, profile.label) - await fs.mkdir(cwd) - await fs.writeFile(path.join(cwd, 'package.json'), '{"private":true,"type":"module"}\n') - await run( - 'npm', - [ - 'install', - '--ignore-scripts', - '--no-audit', - '--no-fund', - '--package-lock=false', - '--omit=dev', - templates, - profile.sdk - ], - { cwd } - ) - const installed = JSON.parse( - await fs.readFile(path.join(cwd, 'node_modules/@bsv/sdk/package.json'), 'utf8') - ) - assert.equal(installed.version, profile.version) - for (const format of ['cjs', 'mjs']) { - const filename = `consumer.${format}` - await fs.writeFile(path.join(cwd, filename), consumerSource(format)) - const { stdout } = await run(process.execPath, [filename], { cwd }) - results.push(JSON.parse(stdout)) - console.log( - `Verified packed templates script construction and signing: SDK ${profile.version}, ${format}.` + // Profiles install into separate directories; formats only read their shared dependencies. + const profileResults = await completeConsumers( + profiles.map(async profile => { + const cwd = path.join(temporary, profile.label) + await fs.mkdir(cwd) + await fs.writeFile(path.join(cwd, 'package.json'), '{"private":true,"type":"module"}\n') + await run( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + '--omit=dev', + templates, + profile.sdk + ], + { cwd } ) - } - } + const installed = JSON.parse( + await fs.readFile(path.join(cwd, 'node_modules/@bsv/sdk/package.json'), 'utf8') + ) + assert.equal(installed.version, profile.version) + return await completeConsumers( + ['cjs', 'mjs'].map(async format => { + const filename = `consumer.${format}` + await fs.writeFile(path.join(cwd, filename), consumerSource(format)) + const { stdout } = await run(process.execPath, [filename], { cwd }) + console.log( + `Verified packed templates script construction and signing: SDK ${profile.version}, ${format}.` + ) + return JSON.parse(stdout) + }) + ) + }) + ) + const results = profileResults.flat() for (const result of results.slice(1)) assert.deepEqual(result, results[0]) } finally { await fs.rm(temporary, { recursive: true, force: true }) From ec12ee79deb69d2019b3e50ee70d975752293d0b Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 15:40:26 -0700 Subject: [PATCH 029/127] fix(wallet): consume sequential operation promises lazily --- docs/reference/package-api-migrations.md | 2 +- governance/package-release-notes.json | 2 +- .../test/brc118-browser.mjs | 13 +++-- packages/wallet/wallet-toolbox/CHANGELOG.md | 2 +- .../wallet-toolbox/benchmarks/sync-fixture.ts | 10 ++-- .../client/test/sync-browser.ts | 9 +++- .../src/storage/StorageProvider.ts | 18 ++++--- .../src/storage/WalletStorageManager.ts | 9 ++-- .../src/storage/methods/proofWork.ts | 12 ++--- .../src/storage/methods/repairBeefProofs.ts | 5 +- .../src/utility/__tests__/runInSeries.test.ts | 49 +++++++++++++++++++ .../wallet-toolbox/src/utility/runInSeries.ts | 11 +++++ 12 files changed, 110 insertions(+), 32 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/utility/__tests__/runInSeries.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/runInSeries.ts diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index cde2db74a..ac6f956f6 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -523,7 +523,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Makes sequential storage and proof iteration explicit while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. - Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index a63123231..67f441d56 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,7 +217,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Makes sequential storage and proof iteration explicit while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts.", + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly.", "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records." }, { diff --git a/packages/middleware/payment-express-middleware/test/brc118-browser.mjs b/packages/middleware/payment-express-middleware/test/brc118-browser.mjs index f35ba8dd1..0cbb4def8 100644 --- a/packages/middleware/payment-express-middleware/test/brc118-browser.mjs +++ b/packages/middleware/payment-express-middleware/test/brc118-browser.mjs @@ -45,6 +45,10 @@ async function listen(server) { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) return `http://127.0.0.1:${server.address().port}` } +async function* receivers(cases) { + for (const item of cases) yield receiver(item).then(target => ({ item, target })) +} + async function receiver({ multipart = true, exposeTransport = true } = {}) { const app = express() const wallet = new ProtoWallet(new PrivateKey(23)) @@ -134,8 +138,7 @@ try { { contentType: 'text/plain', body: [], ancestorBytes: 0, multipart: false } ] // Reuse the page only after the preceding wallet scenario has settled. - for await (const item of cases) { - const target = await receiver(item) + for await (const { item, target } of receivers(cases)) { const result = await page.evaluate(async args => await globalThis.pay(args), { ...item, origin: target.origin @@ -182,8 +185,10 @@ try { assert.equal(manifest.version, '2.8.0') legacyBundle = await build({ ...bundleOptions, alias: { '@bsv/sdk': legacyModule } }) await page.addScriptTag({ url: `${pageOrigin}/legacy.js`, type: 'module' }) - for await (const multipart of [true, false]) { - const target = await receiver({ multipart }) + for await (const { + item: { multipart }, + target + } of receivers([{ multipart: true }, { multipart: false }])) { const item = { origin: target.origin, ancestorBytes: 0, diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 895543903..d708b506d 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -28,7 +28,7 @@ attention to changes that materially alter behavior or extend functionality. monitor updates against primary replacement and concurrent proof corrections. - Keep proof changes and prepared-BEEF invalidation atomic, and retain safe custom provider behavior. No persisted-schema migration is required. -- Make sequential storage and proof iteration explicit while preserving the +- Use lazy asynchronous producers for sequential storage and proof work, preserving the eight-worker proof limit, failure draining, deterministic transaction order and Promise rejection contracts. - Exercise large copies, tombstones, restart/lost acknowledgements and foreground diff --git a/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts b/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts index e7dbd1066..6bc6ee757 100644 --- a/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts +++ b/packages/wallet/wallet-toolbox/benchmarks/sync-fixture.ts @@ -1,3 +1,4 @@ +import { runInSeries } from '../src/utility/runInSeries' import { Script, Transaction, MerklePath } from '@bsv/sdk' import type { StorageProvider } from '../src/storage/StorageProvider' @@ -22,8 +23,8 @@ export async function seedSyncBenchmark( const timestamp = new Date(1_700_000_000_000) await storage.transaction(async trx => { // Seed in order on one transaction without queuing thousands of pending writes. - for await (const i of fixtureIndexes(labels)) - await storage.insertTxLabel( + await runInSeries(fixtureIndexes(labels), i => + storage.insertTxLabel( { txLabelId: 0, userId: user.userId, @@ -34,7 +35,8 @@ export async function seedSyncBenchmark( }, trx ) - for await (const i of fixtureIndexes(proofCount)) { + ) + await runInSeries(fixtureIndexes(proofCount), async i => { const tx = new Transaction() tx.addOutput({ satoshis: i + 1, lockingScript: Script.fromASM(`OP_FALSE OP_RETURN ${'01'.repeat(proofBytes)}`) }) const txid = tx.id('hex') @@ -70,7 +72,7 @@ export async function seedSyncBenchmark( }, trx ) - } + }) }) return user } diff --git a/packages/wallet/wallet-toolbox/client/test/sync-browser.ts b/packages/wallet/wallet-toolbox/client/test/sync-browser.ts index fef4d0ac5..b03b5af52 100644 --- a/packages/wallet/wallet-toolbox/client/test/sync-browser.ts +++ b/packages/wallet/wallet-toolbox/client/test/sync-browser.ts @@ -26,6 +26,12 @@ async function close(storage: StorageIdb) { await storage.dropAllData() } +async function* destinationModes() { + for (const mode of ['exclusive', 'paged'] as const) { + yield open().then(destination => ({ mode, destination })) + } +} + async function benchmark() { const source = await open() const identityKey = PrivateKey.fromRandom().toPublicKey().toString() @@ -47,8 +53,7 @@ async function benchmark() { } try { // Modes share a source fixture and must be measured without competing workloads. - for await (const mode of ['exclusive', 'paged'] as const) { - const destination = await open() + for await (const { mode, destination } of destinationModes()) { const capabilities = destination.getCapabilities.bind(destination) if (mode === 'exclusive') destination.getCapabilities = async () => ({ ...(await capabilities()), storageAccess: undefined }) diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index 200d50ba8..d10958b4e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -1,3 +1,4 @@ +import { runInSeries } from '../utility/runInSeries' import { findProofRecords, mapProofWork } from './methods/proofWork' import { snapshotSyncPage } from './sync/snapshotSyncPage' import { @@ -1463,14 +1464,17 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal // in IndexedDB) and makes the page checkpoint atomic with its data changes. return await this.transaction(async trx => { // Serial sorted reads retain deterministic lock ordering on the shared transaction. - for await (const [, previous] of [...expected.values()] - .sort((left, right) => left.txid.localeCompare(right.txid)) - .entries()) { - const current = verifyOneOrNone(await this.findProvenTxs({ partial: { txid: previous.txid }, trx })) - if (current == null || !sameSyncProof(current, previous)) { - throw new WERR_INVALID_OPERATION('Proof changed during sync preparation; resume from the durable checkpoint.') + await runInSeries( + [...expected.values()].sort((left, right) => left.txid.localeCompare(right.txid)), + async previous => { + const current = verifyOneOrNone(await this.findProvenTxs({ partial: { txid: previous.txid }, trx })) + if (current == null || !sameSyncProof(current, previous)) { + throw new WERR_INVALID_OPERATION( + 'Proof changed during sync preparation; resume from the durable checkpoint.' + ) + } } - } + ) const user = verifyTruthy( verifyOneOrNone(await this.findUsers({ partial: { identityKey: args.identityKey }, trx })) ) diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index c70883710..a1dd31593 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -1,3 +1,4 @@ +import { runInSeries } from '../utility/runInSeries' import { type ValidCreateActionArgs, type ValidListActionsArgs, @@ -196,11 +197,11 @@ export class WalletStorageManager implements sdk.WalletStorage { private async preflightManagedNetworks(peer?: TableSettings): Promise { let reference = peer // The first available store establishes the network for each later store. - for await (const store of this._stores) { + await runInSeries(this._stores, async store => { store.settings ??= await store.storage.makeAvailable() if (reference != null) assertSyncNetwork(reference, store.settings) reference ??= store.settings - } + }) } private selectActiveFromStore(store: ManagedStorage, backups: ManagedStorage[]): void { @@ -691,7 +692,7 @@ export class WalletStorageManager implements sdk.WalletStorage { if (ptxs.length === 0) return await active.transaction(async trx => { // Keep proof writes and their result log in source order within this transaction. - for await (const [index, ptx] of ptxs.entries()) { + await runInSeries(ptxs.entries(), async ([index, ptx]) => { const { result: proof, replacement } = prepared[index] result.log += proof.log if (replacement !== undefined && proof.updated !== undefined) { @@ -704,7 +705,7 @@ export class WalletStorageManager implements sdk.WalletStorage { } } else if (proof.unchanged) result.unchanged.push(ptx) else result.unavailable.push(ptx) - } + }) // Even unavailable replacements invalidate material built from the // orphaned header. Proof rows and the prepared epoch commit atomically. await invalidatePreparedBeefs(active, trx) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts index 04e178f37..e351a24ae 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/proofWork.ts @@ -1,3 +1,4 @@ +import { runInSeries } from '../../utility/runInSeries' import type { StorageProvider } from '../StorageProvider' import type { TrxToken } from '../../sdk/WalletStorage.interfaces' import type { TableProvenTx } from '../schema/tables' @@ -15,9 +16,9 @@ export async function findProofRecords( const unique = [...new Set(txids)] const records: TableProvenTx[] = [] // Consume one SQL batch at a time; callers may share a transaction connection. - for await (const batch of proofBatches(unique)) { + await runInSeries(proofBatches(unique), async batch => { records.push(...(await storage.findProvenTxs({ partial: {}, txids: batch, trx }))) - } + }) return records } @@ -31,16 +32,15 @@ export async function mapProofWork(items: T[], work: (item: T) => Promise< } const workers = Array.from({ length: Math.min(items.length, 8) }, async () => { // Each worker consumes serially; all eight share the same admission cursor. - for await (const index of pendingIndexes()) { - // A peer may fail while the iterator hands this worker its next index. - if (failed) break + await runInSeries(pendingIndexes(), async index => { + if (failed) return try { results[index] = await work(items[index]) } catch (error) { failed = true throw error } - } + }) }) const settled = await Promise.allSettled(workers) for (const result of settled) if (result.status === 'rejected') throw result.reason diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts b/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts index 95272256e..37edfe8b6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/repairBeefProofs.ts @@ -1,3 +1,4 @@ +import { runInSeries } from '../../utility/runInSeries' import { findProofRecords, mapProofWork } from './proofWork' import { Beef, MerklePath } from '@bsv/sdk' import { WERR_INVALID_MERKLE_ROOT } from '../../sdk/WERR_errors' @@ -87,9 +88,9 @@ export async function repairBeefProofs(storage: StorageProvider, beef: Beef, trx await storage.transaction(async trx => { let changed = false // Preserve transaction write order and finish each compare-and-set before the next. - for await (const { proof, expected } of replacements) { + await runInSeries(replacements, async ({ proof, expected }) => { if (expected != null) changed = (await storage.compareAndSetProvenTxProof(expected, proof, trx)) || changed - } + }) const extension = storage as StorageProvider & { invalidatePreparedBeefs?: (trx?: TrxToken) => Promise } if (changed) await extension.invalidatePreparedBeefs?.(trx) }, trx) diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests__/runInSeries.test.ts b/packages/wallet/wallet-toolbox/src/utility/__tests__/runInSeries.test.ts new file mode 100644 index 000000000..34efc79f5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/__tests__/runInSeries.test.ts @@ -0,0 +1,49 @@ +import { runInSeries } from '../runInSeries' + +test('pulls lazily and starts only one operation at a time', async () => { + let release!: () => void + const gate = new Promise(resolve => { + release = resolve + }) + const started: number[] = [] + const completed: number[] = [] + const pending = runInSeries([1, 2, 3], async value => { + started.push(value) + if (value === 1) await gate + expect(completed).toEqual(started.slice(0, -1)) + completed.push(value) + return value * 2 + }) + await Promise.resolve() + expect(started).toEqual([1]) + expect(completed).toEqual([]) + release() + await expect(pending).resolves.toBe(6) + expect(completed).toEqual([1, 2, 3]) +}) + +test.each(['throw', 'reject'] as const)('stops and closes its input on an operation %s', async mode => { + let closed = false + function* values() { + try { + yield* [1, 2, 3] + } finally { + closed = true + } + } + const failure = new Error('operation failed') + const work = jest.fn((value: number) => { + if (value !== 2) return Promise.resolve(value) + if (mode === 'throw') throw failure + return Promise.reject(failure) + }) + await expect(runInSeries(values(), work)).rejects.toBe(failure) + expect(work.mock.calls).toEqual([[1], [2]]) + expect(closed).toBe(true) +}) + +test('does not call work for an empty source', async () => { + const work = jest.fn(() => Promise.resolve(1)) + await expect(runInSeries([], work)).resolves.toBeUndefined() + expect(work).not.toHaveBeenCalled() +}) diff --git a/packages/wallet/wallet-toolbox/src/utility/runInSeries.ts b/packages/wallet/wallet-toolbox/src/utility/runInSeries.ts new file mode 100644 index 000000000..b9812dc88 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/runInSeries.ts @@ -0,0 +1,11 @@ +/** Start each operation only after its predecessor settles; return the last result. */ +export async function runInSeries(values: Iterable, work: (value: T) => Promise): Promise { + async function* results() { + for (const value of values) yield work(value) + } + let last: R | undefined + for await (last of results()) { + // Pull lazily: the async generator awaits each operation before accepting the next. + } + return last +} From de912e7e8e15a031e8567fdb7e46e91af1179f6d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 17:35:20 -0700 Subject: [PATCH 030/127] Capture portable wallet data from a coherent provider read view --- docs/guides/wallet-data-portability.md | 30 +++- docs/reference/package-api-migrations.md | 84 ++++----- governance/package-release-notes.json | 12 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 8 + packages/wallet/wallet-toolbox/README.md | 12 ++ .../wallet-toolbox/src/storage/StorageIdb.ts | 39 +++- .../wallet-toolbox/src/storage/StorageKnex.ts | 20 +++ .../src/storage/StorageProvider.ts | 15 ++ .../src/storage/StorageReader.ts | 4 +- .../src/storage/portable/index.ts | 108 +++++++++--- .../src/storage/portable/snapshot.test.ts | 166 ++++++++++++++++++ .../test/storage/portable.test.ts | 79 +++++++++ specs/wallet/sync-portability-program.md | 91 ++++++++++ 13 files changed, 585 insertions(+), 83 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/snapshot.test.ts create mode 100644 specs/wallet/sync-portability-program.md diff --git a/docs/guides/wallet-data-portability.md b/docs/guides/wallet-data-portability.md index 4ae3e22a7..4caaf6d1e 100644 --- a/docs/guides/wallet-data-portability.md +++ b/docs/guides/wallet-data-portability.md @@ -35,6 +35,34 @@ upgrading. Pending sync work, including [#569](https://github.com/bsv-blockchain/ts-stack/pull/569), is not an available API contract for this guide. +### Unpublished 2.15 source-capture checkpoint + +PR #569 now captures BRC-38 source metadata, user and all standard table reads +inside one provider read view. SQLite uses one transaction; MySQL explicitly +requests repeatable-read isolation; IndexedDB uses one readonly transaction +covering settings and the wallet stores. SQLite WAL permits an independent +writer during capture. IndexedDB queues overlapping writers until capture ends; +this checkpoint does not claim bounded foreground write latency for that phase. +MySQL's configuration is implemented but still requires live qualification. + +Pass `{ requireSnapshot: true }` to `exportBRC38`, `exportBRC38Json` or the +`exportBRC39` options to require a coherent source view. Custom `StorageProvider` +implementations opt in with `supportsReadSnapshot()` and `readSnapshot(callback)` +and must honor the token on every query. A required but unsupported snapshot +is refused before table capture. Without that option, old custom providers retain +the documented caller-quiesced legacy path; it does not gain a snapshot guarantee. +The callback is for database capture only, without peer I/O or progress handlers. +Source JSON history omits null/undefined values only for recognized optional +object properties in a detached copy, retaining false, zero, empty strings and +every array position. Required values remain subject to validation. A null array +entry is rejected rather than dropped. The source records are not rewritten. + +This is an intermediate source candidate, not a released streaming export API. +The materialized helpers below still allocate the full document/file. Remote +snapshot handles, bounded immutable paging, streaming files, staged recovery and +the push/backup scheduling changes remain required parts of the +[full implementation program](https://github.com/bsv-blockchain/ts-stack/blob/codex/wallet-sync-interop-reliability/specs/wallet/sync-portability-program.md). + ## Coverage and limits The implementation exports one `user`, its `sourceStorage` metadata and 13 @@ -66,7 +94,7 @@ qualify an isolated copy through the supported migration/export path. Record both versions; importing a file on the candidate does not prove that an older wallet can produce that file unchanged. -The current helpers read multiple tables and materialize the complete document +The released helpers read multiple tables and materialize the complete document and encrypted file in memory. They do not take a database-wide snapshot, provide streaming archive I/O, expose an archive progress/cancellation API, or promise a coherent view while other writers change the source. Use a stable diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index ac6f956f6..b40815a06 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -537,8 +537,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -549,8 +549,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 67f441d56..4d33b1cc4 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records." + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records." + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." }, { "name": "create-bsv-app", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index d708b506d..db415d96d 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,14 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Capture BRC-38 source metadata and every table from one provider read view. + Custom providers opt in with `supportsReadSnapshot` and `readSnapshot`. The + additive `requireSnapshot` export option refuses unsupported views; old + custom-provider calls keep their caller-quiesced compatibility path. SQLite, + MySQL and IndexedDB implement the local boundary. Recognized optional nullable + JSON object fields normalize in a detached copy without + dropping array values. Streaming files and the full #544 program remain in + implementation; IndexedDB writers still wait during source capture. - Add resumable local atomic pages, durable checkpoints, cancellation/progress, concurrent read capability checks and fair foreground/background ownership. - Separate fixed source/commit latency from marginal row cost and bound upward diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 20704a9fe..d78aa29d9 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -28,6 +28,18 @@ These guides cover the recovery design. The measurements below describe specific tests and do not establish that every wallet product has complete key-and-data disaster recovery. +The unpublished 2.15 candidate captures BRC-38 source settings, wallet identity +and standard table closure in one local provider read view. SQLite and IndexedDB +tests cover independent writes during capture; MySQL explicitly requests +repeatable-read isolation but still needs live qualification. Custom providers +opt in with `supportsReadSnapshot` and `readSnapshot`. Use the export option +`requireSnapshot: true` to refuse unsupported capture; old custom-provider calls +retain their documented caller-quiesced fallback. +Recognized optional nullable JSON fields are omitted in a detached archive copy; +array entries and meaningful falsy values are preserved. The helpers still +materialize the full document/file, and IndexedDB writers wait during capture. +The complete sync/streaming/restore program remains in progress on #569. + ## Backup and sync: tested results **Live E2E testing used a large wallet in the native desktop client**, covering diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts index 59dd6725f..3232bc86f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts @@ -217,8 +217,13 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider * * @param trx */ - async readSettings(_trx?: TrxToken): Promise { + async readSettings(trx?: TrxToken): Promise { await this.verifyDB() + if (trx != null) { + const rows = await this.toDbTrx(['settings'], 'readonly', trx).objectStore('settings').getAll() + if (rows.length !== 1) throw new WERR_INTERNAL('Wallet snapshot requires exactly one settings row') + return this.validateEntity(rows[0]) + } return this._settings as TableSettings } @@ -1546,11 +1551,33 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider if (trx == null) await tx.done } - /** - * @param scope - * @param trx - * @returns - */ + override supportsReadSnapshot(): boolean { + return true + } + + /** Capture settings and wallet tables in one local readonly view. */ + override async readSnapshot(read: (trx: TrxToken) => Promise): Promise { + const db = await this.verifyDB() + // One readonly transaction gives all wallet tables the same view. This + // permits other reads; IndexedDB serializes overlapping writers until + // capture ends. No peer I/O or consumer callback belongs in this scope. + const tx = db.transaction(['settings', ...this.allStores], 'readonly') + try { + const result = await read(tx as TrxToken) + await tx.done + return result + } catch (error) { + try { + tx.abort() + await tx.done + } catch { + // An already-aborted/finished transaction needs no further cleanup; + // preserve the original capture error rather than its abort result. + } + throw error + } + } + async transaction(scope: (trx: TrxToken) => Promise, trx?: TrxToken): Promise { if (trx != null) return await scope(trx) diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 1123752d0..c4472cc29 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -168,6 +168,26 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide protected override supportsStorageAccessScheduling(): boolean { return true } + + override supportsReadSnapshot(): boolean { + return this.databaseSystem() === 'sqlite' || this.databaseSystem() === 'mysql' + } + + override async readSnapshot(read: (trx: TrxToken) => Promise): Promise { + await this.makeAvailable() + const database = this.databaseSystem() + if (database === 'sqlite') { + // SQLite establishes its read view on the first query. Do not request + // Knex's unsupported SQLite isolation/readOnly options or write settings + // into the shared connection. WAL writers may use another connection. + return await this.knex.transaction(read) + } + if (database === 'mysql') { + return await this.knex.transaction(read, { isolationLevel: 'repeatable read', readOnly: true }) + } + throw new WERR_NOT_IMPLEMENTED('Coherent wallet source snapshots require SQLite or MySQL isolation') + } + protected override supportsNoSendExpiryPersistence(): boolean { return true } diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index d10958b4e..2d3e44139 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -516,6 +516,21 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal return false } + /** Local provider contract only; this does not advertise a remote export RPC. */ + supportsReadSnapshot(): boolean { + return false + } + + /** + * Capture related rows from one database read view. The callback must use + * this token for every query and must not perform network or file I/O. + * Custom providers must implement their actual isolation guarantee rather + * than inheriting an ordinary transaction with unknown isolation. + */ + async readSnapshot(_read: (trx: TrxToken) => Promise): Promise { + throw new WERR_NOT_IMPLEMENTED('Coherent wallet source snapshots are not supported by this provider') + } + protected supportsActionBatchPersistence(): boolean { return false } diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts b/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts index 46e209541..ea40d8e16 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts @@ -116,8 +116,8 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { return undefined } - async findUserByIdentityKey(key: string): Promise { - return verifyOneOrNone(await this.findUsers({ partial: { identityKey: key } })) + async findUserByIdentityKey(key: string, trx?: sdk.TrxToken): Promise { + return verifyOneOrNone(await this.findUsers({ partial: { identityKey: key }, trx })) } async getSyncChunk(args: sdk.RequestSyncChunkArgs): Promise { diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/index.ts b/packages/wallet/wallet-toolbox/src/storage/portable/index.ts index dd9404571..d8a9710dc 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/index.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/index.ts @@ -7,6 +7,7 @@ import { AESGCM, AESGCMDecrypt } from '@bsv/sdk/primitives/AESGCM' import { toArray, toBase64, toUTF8 } from '@bsv/sdk/primitives/utils' import { argon2id } from '../../utility/hashWasm' import { StorageProvider } from '../StorageProvider' +import { runInSeries } from '../../utility/runInSeries' import { TableCertificate, TableCertificateField, @@ -71,7 +72,13 @@ export interface BRC38ImportResult { updates: number } -export interface BRC39Options { +export interface BRC38ExportOptions { + /** Refuse providers without a coherent source read view. Old custom providers + * otherwise retain the caller-quiesced legacy path; built-ins use snapshots. */ + requireSnapshot?: boolean +} + +export interface BRC39Options extends BRC38ExportOptions { iterations?: number memoryKiB?: number parallelism?: number @@ -168,38 +175,51 @@ const dateFieldsByKind: Partial> = { syncState: ['created_at', 'updated_at', 'when'] } -export async function exportBRC38(storage: StorageProvider, identityKey: string): Promise { - const sourceStorage = await storage.makeAvailable() - const user = verifyTruthy(await storage.findUserByIdentityKey(identityKey)) +export async function exportBRC38( + storage: StorageProvider, + identityKey: string, + options: BRC38ExportOptions = {} +): Promise { + await storage.makeAvailable() + if (options.requireSnapshot === true || storage.supportsReadSnapshot()) { + return await storage.readSnapshot(trx => captureBRC38(storage, identityKey, trx)) + } + return await captureBRC38(storage, identityKey) +} + +async function captureBRC38( + storage: StorageProvider, + identityKey: string, + trx?: sdk.TrxToken +): Promise { + const sourceStorage = await storage.readSettings(trx) + const user = verifyTruthy(await storage.findUserByIdentityKey(identityKey, trx)) const userId = user.userId - const transactions = await storage.findTransactions({ partial: { userId } }) - const transactionIds = new Set(transactions.map(t => t.transactionId)) + const transactions = await storage.findTransactions({ partial: { userId }, trx }) const transactionTxids = new Set(transactions.map(t => t.txid).filter((txid): txid is string => txid != null)) - const provenTxReqs = (await storage.getProvenTxReqsForUser({ userId })).filter(r => transactionTxids.has(r.txid)) + const provenTxReqs = (await storage.getProvenTxReqsForUser({ userId, trx })).filter(r => transactionTxids.has(r.txid)) const provenTxIds = new Set() for (const tx of transactions) if (tx.provenTxId != null) provenTxIds.add(tx.provenTxId) for (const req of provenTxReqs) if (req.provenTxId != null) provenTxIds.add(req.provenTxId) const provenTxs: TableProvenTx[] = [] - for (const provenTxId of Array.from(provenTxIds).sort(compareNumber)) { - const proven = verifyOneOrNone(await storage.findProvenTxs({ partial: { provenTxId } })) + await runInSeries(Array.from(provenTxIds).sort(compareNumber), async provenTxId => { + const proven = verifyOneOrNone(await storage.findProvenTxs({ partial: { provenTxId }, trx })) if (proven != null) provenTxs.push(proven) - } + }) - const outputBaskets = await storage.findOutputBaskets({ partial: { userId } }) - const commissions = await storage.findCommissions({ partial: { userId } }) - const outputs = await storage.findOutputs({ partial: { userId } }) - const outputTags = await storage.findOutputTags({ partial: { userId } }) - const outputTagMaps = (await storage.getOutputTagMapsForUser({ userId })).filter(m => - outputs.some(o => o.outputId === m.outputId) - ) - const txLabels = await storage.findTxLabels({ partial: { userId } }) - const txLabelMaps = (await storage.getTxLabelMapsForUser({ userId })).filter(m => transactionIds.has(m.transactionId)) - const certificates = await storage.findCertificates({ partial: { userId } }) - const certificateFields = await storage.findCertificateFields({ partial: { userId } }) - const syncStates = await storage.findSyncStates({ partial: { userId } }) + const outputBaskets = await storage.findOutputBaskets({ partial: { userId }, trx }) + const commissions = await storage.findCommissions({ partial: { userId }, trx }) + const outputs = await storage.findOutputs({ partial: { userId }, trx }) + const outputTags = await storage.findOutputTags({ partial: { userId }, trx }) + const outputTagMaps = await storage.getOutputTagMapsForUser({ userId, trx }) + const txLabels = await storage.findTxLabels({ partial: { userId }, trx }) + const txLabelMaps = await storage.getTxLabelMapsForUser({ userId, trx }) + const certificates = await storage.findCertificates({ partial: { userId }, trx }) + const certificateFields = await storage.findCertificateFields({ partial: { userId }, trx }) + const syncStates = await storage.findSyncStates({ partial: { userId }, trx }) const data: BRC38WalletData = { brc: 38, @@ -234,8 +254,12 @@ export async function exportBRC38(storage: StorageProvider, identityKey: string) return data } -export async function exportBRC38Json(storage: StorageProvider, identityKey: string): Promise { - return canonicalize(await exportBRC38(storage, identityKey)) +export async function exportBRC38Json( + storage: StorageProvider, + identityKey: string, + options?: BRC38ExportOptions +): Promise { + return canonicalize(await exportBRC38(storage, identityKey, options)) } export function parseBRC38Json(json: string): BRC38WalletData { @@ -272,7 +296,7 @@ export async function exportBRC39( password: string, options?: BRC39Options ): Promise { - return await encryptBRC39(await exportBRC38(storage, identityKey), password, options) + return await encryptBRC39(await exportBRC38(storage, identityKey, options), password, options) } export async function importBRC39( @@ -876,7 +900,7 @@ function portableRow(kind: string, row: object): PortableRow { if (binaryFields.has(key)) { out[key] = toBase64(value as number[]) } else if (jsonFields.has(key)) { - out[key] = typeof value === 'string' ? (JSON.parse(value) as JsonValue) : (value as JsonValue) + out[key] = portableJson(typeof value === 'string' ? JSON.parse(value) : value, key) } else if (value instanceof Date) { out[key] = isoDate(value) } else { @@ -886,6 +910,38 @@ function portableRow(kind: string, row: object): PortableRow { return out } +/** Legacy JSON objects may store absent optional fields as null. Keep array + * positions and meaningful falsy values; never mutate the captured source. */ +function portableJson(value: unknown, field: string, path: Array = []): JsonValue { + if (Array.isArray(value)) return value.map((child, index) => portableJson(child, field, [...path, index])) + if (isObject(value)) { + const entries: Array<[string, JsonValue]> = [] + for (const [key, child] of Object.entries(value)) { + const childPath = [...path, key] + if (child == null && optionalPortableJsonField(field, childPath)) continue + entries.push([key, portableJson(child, field, childPath)]) + } + return Object.fromEntries(entries) + } + if (typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number' && Number.isFinite(value)) return value + throw new Error('BRC-38 JSON arrays and values must not contain null, undefined or non-JSON values') +} + +function optionalPortableJsonField(field: string, path: Array): boolean { + if (field === 'history') { + return ( + (path.length === 1 && path[0] === 'notes') || + (path.length === 3 && path[0] === 'notes' && typeof path[1] === 'number' && path[2] !== 'what') + ) + } + if (field === 'notify') return path.length === 1 && path[0] === 'transactionIds' + if (field === 'syncMap') { + return path.length === 2 && SYNC_CHUNK_ENTITY_ORDER.includes(String(path[0])) && path[1] === 'maxUpdated_at' + } + return (field === 'errorLocal' || field === 'errorOther') && path.length === 1 && path[0] === 'stack' +} + function fromPortableRow(kind: string, row: PortableRow): T { const out: Record = {} const dateFields = new Set(dateFieldsByKind[kind] ?? []) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/snapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/snapshot.test.ts new file mode 100644 index 000000000..1153900c9 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/snapshot.test.ts @@ -0,0 +1,166 @@ +import 'fake-indexeddb/auto' +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { StorageIdb } from '../StorageIdb' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { exportBRC38, exportBRC38Json, exportBRC39 } from './index' +import { runInSeries } from '../../utility/runInSeries' + +const identity = '02' + '11'.repeat(32) +const foreignIdentity = '03' + '22'.repeat(32) +const stores: StorageProvider[] = [] +const directories: string[] = [] + +async function sqlitePair(): Promise<[StorageKnex, StorageKnex]> { + const directory = await mkdtemp(join(tmpdir(), 'wallet-snapshot-')) + directories.push(directory) + const open = () => { + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 1000 + }) + }) + stores.push(storage) + return storage + } + const source = open() + await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate('original source', 'source-storage') + await source.makeAvailable() + const peer = open() + await peer.makeAvailable() + return [source, peer] +} + +async function idbPair(): Promise<[StorageIdb, StorageIdb]> { + const name = `wallet-snapshot-${randomUUID()}` + const open = async () => { + const storage = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + storage.dbName = name + stores.push(storage) + await storage.migrate('original source', 'source-storage') + await storage.makeAvailable() + return storage + } + return [await open(), await open()] +} + +afterEach(async () => { + jest.restoreAllMocks() + const closed = stores.splice(0) + await runInSeries(closed, store => store.destroy()) + await runInSeries(closed.filter(store => store instanceof StorageIdb), store => store.dropAllData()) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) + +test('SQLite export holds one source view while a separate WAL writer commits between table reads', async () => { + const [source, peer] = await sqlitePair() + const { user } = await source.findOrInsertUser(identity) + await source.findOrInsertTxLabel(user.userId, 'before capture') + const find = source.findTransactions.bind(source) + jest.spyOn(source, 'findTransactions').mockImplementationOnce(async args => { + const rows = await find(args) + await peer.transaction(async trx => { + await peer.updateUser(user.userId, { activeStorage: 'later-primary', updated_at: new Date() }, trx) + await peer.insertTxLabel( + { + txLabelId: 0, + userId: user.userId, + label: 'after capture', + isDeleted: false, + created_at: new Date(), + updated_at: new Date() + }, + trx + ) + }) + return rows + }) + const captured = await exportBRC38(source, identity) + expect(captured.user.activeStorage).toBe(user.activeStorage) + expect(captured.tables.txLabels.map(row => row.label)).toEqual(['before capture']) + expect((await exportBRC38(source, identity)).tables.txLabels.map(row => row.label)).toEqual([ + 'before capture', + 'after capture' + ]) + expect((await peer.findUserByIdentityKey(identity))?.activeStorage).toBe('later-primary') +}) + +test('IndexedDB capture excludes an independently queued write and releases it after capture', async () => { + const [source, peer] = await idbPair() + const { user } = await source.findOrInsertUser(identity) + await source.findOrInsertTxLabel(user.userId, 'before capture') + const find = source.findTransactions.bind(source) + let pending: Promise | undefined + jest.spyOn(source, 'findTransactions').mockImplementationOnce(async args => { + const rows = await find(args) + pending = peer.findOrInsertTxLabel(user.userId, 'after capture') + return rows + }) + const captured = await exportBRC38(source, identity) + expect(captured.tables.txLabels.map(row => row.label)).toEqual(['before capture']) + expect(pending).toBeDefined() + await pending + expect((await exportBRC38(source, identity)).tables.txLabels.map(row => row.label)).toEqual([ + 'before capture', + 'after capture' + ]) +}) + +test.each([sqlitePair, idbPair])( + 'source metadata comes from the database view and wallet profiles remain separate (%#)', + async pair => { + const [source] = await pair() + const { user } = await source.findOrInsertUser(identity) + const { user: foreign } = await source.findOrInsertUser(foreignIdentity) + await source.findOrInsertTxLabel(user.userId, 'own label') + await source.findOrInsertTxLabel(foreign.userId, 'foreign label') + source.getSettings().storageName = 'stale cached name' + const captured = await exportBRC38(source, identity) + expect(captured.sourceStorage.storageName).toBe('original source') + expect(captured.user.identityKey).toBe(identity) + expect(captured.tables.txLabels.map(row => row.label)).toEqual(['own label']) + expect(source.getSettings().storageName).toBe('stale cached name') + } +) + +test.each([sqlitePair, idbPair])('capture failure releases the view without writing wallet data (%#)', async pair => { + const [source] = await pair() + const { user } = await source.findOrInsertUser(identity) + const failure = new Error('capture stopped') + const read = jest.spyOn(source, 'findOutputs').mockRejectedValueOnce(failure) + await expect(exportBRC38(source, identity)).rejects.toBe(failure) + read.mockRestore() + await source.findOrInsertTxLabel(user.userId, 'write after failure') + expect((await exportBRC38(source, identity)).tables.txLabels.map(row => row.label)).toEqual(['write after failure']) +}) + +test('a provider without a coherent read implementation refuses a complete source export', async () => { + const [source] = await sqlitePair() + await source.findOrInsertUser(identity) + jest.spyOn(source, 'readSnapshot').mockImplementation(StorageProvider.prototype.readSnapshot) + jest.spyOn(source, 'supportsReadSnapshot').mockReturnValue(false) + const reads = jest.spyOn(source, 'findTransactions') + await expect(exportBRC38(source, identity, { requireSnapshot: true })).rejects.toThrow( + 'Coherent wallet source snapshots are not supported' + ) + await expect(exportBRC38Json(source, identity, { requireSnapshot: true })).rejects.toThrow( + 'Coherent wallet source snapshots are not supported' + ) + await expect(exportBRC39(source, identity, 'synthetic password', { requireSnapshot: true })).rejects.toThrow( + 'Coherent wallet source snapshots are not supported' + ) + expect(reads).not.toHaveBeenCalled() + const legacy = await exportBRC38(source, identity) + expect(legacy.user.identityKey).toBe(identity) + expect(reads).toHaveBeenCalledTimes(1) +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/portable.test.ts b/packages/wallet/wallet-toolbox/test/storage/portable.test.ts index 881d85239..ac1103c09 100644 --- a/packages/wallet/wallet-toolbox/test/storage/portable.test.ts +++ b/packages/wallet/wallet-toolbox/test/storage/portable.test.ts @@ -113,6 +113,85 @@ describe('BRC-38/39 portable wallet data', () => { await expect(importBRC38(target, document, { mode: 'restore' })).rejects.toThrow(/empty target storage/) }) + test('canonical capture omits absent legacy JSON object fields without changing source history or array positions', async () => { + const source = await createPortableSource('portable_nullable_history', '4'.repeat(64)) + const storage = source.activeStorage + const original = await exportBRC38(storage, source.identityKey) + const partial = { provenTxReqId: original.tables.provenTxReqs[0].provenTxReqId as number } + const request = verifyOne(await storage.findProvenTxReqs({ partial })) + const history = JSON.stringify({ + notes: [{ when: null, what: 'sent', optional: null, retained: false, zero: 0, text: '', ['__proto__']: 'own JSON property' }] + }) + await storage.updateProvenTxReq(request.provenTxReqId, { history }) + const document = await exportBRC38(storage, source.identityKey) + expect(document.tables.provenTxReqs[0].history).toEqual({ + notes: [{ what: 'sent', retained: false, zero: 0, text: '', ['__proto__']: 'own JSON property' }] + }) + expect(verifyOne(await storage.findProvenTxReqs({ partial })).history).toBe(history) + expect(parseBRC38Json(await exportBRC38Json(storage, source.identityKey)).tables.provenTxReqs[0].history) + .toEqual(document.tables.provenTxReqs[0].history) + }) + + test('canonical capture refuses null required history fields instead of hiding them', async () => { + const source = await createPortableSource('portable_null_required', '6'.repeat(64)) + const original = await exportBRC38(source.activeStorage, source.identityKey) + const provenTxReqId = original.tables.provenTxReqs[0].provenTxReqId as number + await source.activeStorage.updateProvenTxReq(provenTxReqId, { history: '{"notes":[{"what":null}]}' }) + await expect(exportBRC38(source.activeStorage, source.identityKey)).rejects.toThrow('must not contain null') + }) + + test.each(['labels', 'tags'])('capture refuses inconsistent %s relationships instead of omitting owned map rows', async kind => { + const source = await createPortableSource(`portable_inconsistent_${kind}`, '8'.repeat(64)) + const setup = source.setup! + if (kind === 'labels') { + await _tu.insertTestTxLabelMap(source.activeStorage, setup.u2tx1, setup.u1label1) + await expect(exportBRC38(source.activeStorage, source.identityKey)).rejects.toThrow('txLabelMap.transactionId') + expect(await source.activeStorage.findTxLabelMaps({ partial: { + transactionId: setup.u2tx1.transactionId, txLabelId: setup.u1label1.txLabelId + } })).toHaveLength(1) + } else { + await _tu.insertTestOutputTagMap(source.activeStorage, setup.u2tx1o0, setup.u1tag1) + await expect(exportBRC38(source.activeStorage, source.identityKey)).rejects.toThrow('outputTagMap.outputId') + expect(await source.activeStorage.findOutputTagMaps({ partial: { + outputId: setup.u2tx1o0.outputId, outputTagId: setup.u1tag1.outputTagId + } })).toHaveLength(1) + } + }) + + test('legacy optional sync/error fields normalize while checkpoint counts and ID mappings remain authoritative', async () => { + const source = await createPortableSource('portable_nullable_checkpoint', '7'.repeat(64)) + const storage = source.activeStorage + const original = await exportBRC38(storage, source.identityKey) + const originalState = verifyTruthy(original.tables.syncStates.find(row => row.storageIdentityKey === remoteSyncStorageIdentityKey)) + const partial = { syncStateId: originalState.syncStateId as number } + const state = verifyOne(await storage.findSyncStates({ partial })) + const map = JSON.parse(state.syncMap) + map.transaction.maxUpdated_at = null + const syncMap = JSON.stringify(map) + const errorLocal = '{"code":"retry","description":"synthetic checkpoint","stack":null}' + await storage.updateSyncState(state.syncStateId, { syncMap, errorLocal }) + const captured = await exportBRC38(storage, source.identityKey) + const row = verifyTruthy(captured.tables.syncStates.find(row => row.syncStateId === state.syncStateId)) + expect(row.errorLocal).toEqual({ code: 'retry', description: 'synthetic checkpoint' }) + delete map.transaction.maxUpdated_at + expect(row.syncMap).toEqual(map) + expect(verifyOne(await storage.findSyncStates({ partial })).syncMap).toBe(syncMap) + map.transaction.idMap['999'] = null + await storage.updateSyncState(state.syncStateId, { syncMap: JSON.stringify(map) }) + await expect(exportBRC38(storage, source.identityKey)).rejects.toThrow('must not contain null') + }) + + test('canonical capture rejects a null array entry instead of silently dropping it', async () => { + const source = await createPortableSource('portable_null_array', '5'.repeat(64)) + const original = await exportBRC38(source.activeStorage, source.identityKey) + const partial = { provenTxReqId: original.tables.provenTxReqs[0].provenTxReqId as number } + const request = verifyOne(await source.activeStorage.findProvenTxReqs({ partial })) + const history = '{"notes":[{"what":"before"},null,{"what":"after"}]}' + await source.activeStorage.updateProvenTxReq(request.provenTxReqId, { history }) + await expect(exportBRC38(source.activeStorage, source.identityKey)).rejects.toThrow('must not contain null') + expect(verifyOne(await source.activeStorage.findProvenTxReqs({ partial })).history).toBe(history) + }) + test('normalizes an exact legacy managed-change default during BRC-38 restore', async () => { const document = minimalDocument() document.tables.outputBaskets.push( diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md new file mode 100644 index 000000000..1e0c761bc --- /dev/null +++ b/specs/wallet/sync-portability-program.md @@ -0,0 +1,91 @@ +# Wallet sync and portability implementation program + +Issue [#544](https://github.com/bsv-blockchain/ts-stack/issues/544) is the acceptance +contract for this program on [#569](https://github.com/bsv-blockchain/ts-stack/pull/569). +The PR remains open, unmerged and draft while implementation or qualification is +incomplete. A green intermediate source revision does not complete this program. + +## Baseline and immediate defect + +At `ec12ee79deb69d2019b3e50ee70d975752293d0b`, `syncFromReaderResumable` yields +between atomic local destination commits. `syncToWriter`, ordinary +`syncFromReader`, `updateBackups` and primary reconciliation still hold exclusive +manager ownership across entire copy loops. Near-realtime backup therefore still +blocks foreground wallet operations. Existing benchmarks cover pull scheduling, +not this push/backup acceptance case. + +Removing those locks alone is insufficient. A source can change between live +offset pages, and an old source reply can overlap primary replacement. Source +isolation, checkpoint ownership and generation fencing must accompany yielding. +Existing BRC-38 capture also reads tables independently and materializes the +complete archive; a local replica export cannot stand in for original remote +source metadata and sync-state history. + +## Required checkpoints + +Each row remains open until its implementation **and** evidence are complete. +The sequence permits intermediate commits, not permanent scope deferrals. + +| ID | Required result | Code and evidence boundary | Baseline | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| S1 | Both directions, ordinary backup and primary reconciliation use bounded resumable work; foreground reads/writes proceed outside atomic commits; fair background admission; explicit cancellation and stale-session rejection | `WalletStorageManager`, `storage/sync`; delayed peer, queued cancellation, lost acknowledgement, concurrent sessions, replacement and restart tests; push and pull traces | Pull subset only | +| S2 | Coherent provider read views, stable indexed keysets and immutable high-water positions preserve closure, equal timestamps and tombstones under monitor/device writes | `StorageProvider`, `StorageKnex`, `StorageIdb`, schemas and migrations; independent writers, process termination, query plans and bounded page tests | Missing coherent view/keysets | +| S3 | Negotiated authenticated single-profile remote snapshot/export handles bind identity, network, source schema/version and immutable content; quotas, expiry, bounded cursors and cleanup | `StorageClientBase`, `StorageClient`, `StorageServer`, shared contracts; real authenticated HTTP, old/new combinations and expiry/restart evidence | Missing | +| S4 | Bounded metadata/blob transport with integrity-checked content reuse, explicit row/byte/in-flight limits, adaptive page control, no repeated full counts/maps, optional measured read-ahead with ordered commits | Sync transfer, provider queries and packed adapters; large individual row, high fixed latency, table-transition and backpressure tests | Binary transfer/controller subset only | +| P1 | Canonical streaming BRC-38 contains all standard tables and relations plus original source provenance/sync state from one snapshot; optional legacy object fields normalize without mutation or lost array values | `storage/portable`, shared vectors and independent reader/writer; full closure, nullable history, binary/order and concurrent capture tests | Materialized non-snapshot capture | +| P2 | Bounded BRC-39 encryption/decryption and file processing retain the exact envelope, NFC password handling, canonical Argon2id strength and complete authentication before activation | Portable codec, worker/native backends and bounded transferable/IPC adapters; independent bytes, wrong password, corrupt/truncated/oversized inputs, progress/cancel and memory evidence | Materialized file processing | +| P3 | Import preview, isolated durable staging, semantic validation, ID remapping, repeat import and a durable commit/recovery journal; preserve provenance/primary history without automatic activation | Portable import, storage migrations/journal; kill/restart at every durable boundary, empty/occupied targets, repeat merge and disk-pressure tests | Legacy restore/merge subset only | +| A1 | One wallet identity/network/profile throughout every export/import/sync; supported versioned packed IDB/native adapters and downstream migration removing coarse queues/private schema copies | Public core/client/mobile artifacts and host example; independent profiles, native browser, supported native databases and mobile lifecycle | Partial existing platform artifacts | +| V1 | Reproducible per-backend and combined performance/fault/conformance evidence with foreground p50/p95/p99, lock/queue time, cancellation, wall/wire/CPU/query/memory costs and quiescent unchanged copies | SQLite, IndexedDB, authenticated remote; small/large wallets and rows, cold/incremental/no-change/export/import, latency/disconnect cohorts | Pull-only benchmark baseline | +| V2 | Complete affected packages, packed consumers, docs, conformance, property/mutation, analyzer and exact-head hosted repository gates | Root governance and CI; no relaxed thresholds, hidden findings or substituted platform claims | Earlier head qualified only | +| R1 | Released packages, provider rollout, consumer migration and explicitly authorized funded restore/spend plus required physical platform drills | Protected release/deployment workflows and host/operator acceptance records | Separate authorization and execution required | + +## Compatibility and data boundaries + +- Additive capabilities precede their use. Unsupported providers retain the safe + serialized path and explicitly refuse unsupported complete source exports. + Old/new combinations must not silently advertise stronger guarantees. +- Preserve released APIs, BRC-38/39/40 encodings, error identities and inclusive + timestamp behavior. New cursor/schema contracts are versioned and tested with + resumable upgrades and documented downgrade constraints. +- BRC-38 v1 deliberately excludes pending action batches, auxiliary runtime + tables, application-specific tables and storage-global logs. Do not settle + pending batches or invent an archive extension to make export work. +- Preserve historical managed-change policy in archive bytes. Operational + migration is separate from faithful export/import provenance. +- Key recovery and wallet-data recovery remain distinct. All automated fixtures + use synthetic records; production keys, archives, payloads and identities do + not enter telemetry, public coordination or source evidence. +- Keep all memory, temporary disk, retention, queue and KDF limits explicit. + Cancellation before commit discards staged work; cancellation during a commit + reports the durable outcome before stopping. No unverified plaintext becomes + active wallet data. + +## Completion evidence + +The first implementation checkpoint adds local provider read views and threads +their token through every BRC-38 capture query, including uncached source +settings. Built-in providers use that view automatically. `requireSnapshot` +requests the guarantee explicitly and refuses unsupported providers before table +reads; existing custom-provider calls keep their caller-quiesced compatibility +path. Tests cover an independent SQLite WAL writer, queued IndexedDB writes, +failure cleanup, profile separation and stale settings caches. Optional legacy +JSON fields normalize without changing source history; required values and array +entries cannot be silently dropped. Inconsistent owned label/tag mappings reject +capture instead of being filtered out of the archive. + +This checkpoint is only part of S2/P1. It does not yet implement keysets, immutable +retained snapshots, streaming, bounded push/backup work or staged restore. +IndexedDB writers wait during capture and the legacy helpers still materialize +the document. MySQL's explicit repeatable-read path needs live qualification. + +For every checkpoint record the exact source revision, commands, fixture and +platform, measured result, compatibility result and remaining limitation. Link +evidence to the requirement it actually demonstrates. Native browser evidence +does not qualify physical mobile execution; synthetic signing does not qualify a +funded restore/spend; source CI does not constitute publication or deployment. + +The implementation goal stays active until all required work is complete or an +explicit external dependency prevents further progress. Prepare concrete release, +deployment and funded-drill artifacts before requesting the separate operator +authorization those actions require. PR #569 must remain open even when complete. From bd7043aa1427eed7828f6b122f0d90b6cb0bd2e7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 17:45:00 -0700 Subject: [PATCH 031/127] Qualify MySQL snapshot setup and failure cleanup --- docs/guides/wallet-data-portability.md | 4 +- docs/reference/package-api-migrations.md | 78 ++++++++--------- governance/package-release-notes.json | 6 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 4 +- packages/wallet/wallet-toolbox/README.md | 5 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 31 ++++++- .../src/storage/StorageProvider.ts | 6 +- .../storage/portable/mysqlSnapshot.test.ts | 85 +++++++++++++++++++ specs/wallet/sync-portability-program.md | 4 +- 9 files changed, 173 insertions(+), 50 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts diff --git a/docs/guides/wallet-data-portability.md b/docs/guides/wallet-data-portability.md index 4caaf6d1e..ae539302f 100644 --- a/docs/guides/wallet-data-portability.md +++ b/docs/guides/wallet-data-portability.md @@ -43,7 +43,9 @@ requests repeatable-read isolation; IndexedDB uses one readonly transaction covering settings and the wallet stores. SQLite WAL permits an independent writer during capture. IndexedDB queues overlapping writers until capture ends; this checkpoint does not claim bounded foreground write latency for that phase. -MySQL's configuration is implemented but still requires live qualification. +A local MySQL 8.4.11 fixture with independent connections verifies source +isolation, enforced read-only access, unchanged session defaults and failure +cleanup. This is not deployed-provider or PXC recovery qualification. Pass `{ requireSnapshot: true }` to `exportBRC38`, `exportBRC38Json` or the `exportBRC39` options to require a coherent source view. Custom `StorageProvider` diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index b40815a06..cf94f7505 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -524,7 +524,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) - Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -538,7 +538,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) - Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -550,7 +550,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) - Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 4d33b1cc4..c99a95f3e 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -218,21 +218,21 @@ "publishedVersion": "2.14.4", "releaseType": "minor", "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." }, { "name": "create-bsv-app", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index db415d96d..a94a76265 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -10,7 +10,9 @@ attention to changes that materially alter behavior or extend functionality. Custom providers opt in with `supportsReadSnapshot` and `readSnapshot`. The additive `requireSnapshot` export option refuses unsupported views; old custom-provider calls keep their caller-quiesced compatibility path. SQLite, - MySQL and IndexedDB implement the local boundary. Recognized optional nullable + MySQL and IndexedDB implement the local boundary. MySQL uses valid + next-transaction characteristics on one reserved connection and closes failed + connections before returning them to the pool. Recognized optional nullable JSON object fields normalize in a detached copy without dropping array values. Streaming files and the full #544 program remain in implementation; IndexedDB writers still wait during source capture. diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index d78aa29d9..7f796269a 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -30,8 +30,9 @@ key-and-data disaster recovery. The unpublished 2.15 candidate captures BRC-38 source settings, wallet identity and standard table closure in one local provider read view. SQLite and IndexedDB -tests cover independent writes during capture; MySQL explicitly requests -repeatable-read isolation but still needs live qualification. Custom providers +tests cover independent writes during capture. A local MySQL 8.4.11 fixture +verifies repeatable-read isolation, read-only enforcement and connection cleanup +without changing session defaults; deployed/PXC recovery remains unqualified. Custom providers opt in with `supportsReadSnapshot` and `readSnapshot`. Use the export option `requireSnapshot: true` to refuse unsupported capture; old custom-provider calls retain their documented caller-quiesced fallback. diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index c4472cc29..12b2d642a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -183,11 +183,40 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return await this.knex.transaction(read) } if (database === 'mysql') { - return await this.knex.transaction(read, { isolationLevel: 'repeatable read', readOnly: true }) + return await this.readMySQLSnapshot(read) } throw new WERR_NOT_IMPLEMENTED('Coherent wallet source snapshots require SQLite or MySQL isolation') } + private async readMySQLSnapshot(read: (trx: TrxToken) => Promise): Promise { + const client = this.knex.client + const connection = await client.acquireConnection() + try { + // MySQL requires a comma between transaction characteristics. Knex's + // combined isolationLevel/readOnly options currently omit it. Reserve + // one connection, set only its NEXT transaction, then let Knex own the + // begin/commit/rollback lifecycle without changing pool session defaults. + await this.knex.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY').connection(connection) + return await this.knex.transaction( + async trx => { + if (trx.isCompleted()) throw new WERR_INTERNAL('MySQL snapshot transaction did not begin') + return await read(trx) + }, + { connection } + ) + } catch (error) { + // A setup/begin failure can leave pending transaction characteristics. + // Closing before release prevents a later wallet write inheriting them. + // Driver destroy also removes native mysql2 pooled connections; end() + // may merely return those connections to their external pool. + connection.destroy() + throw error + } finally { + delete connection.__knexTxId + await client.releaseConnection(connection) + } + } + protected override supportsNoSendExpiryPersistence(): boolean { return true } diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index 2d3e44139..ae347a939 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -527,8 +527,10 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal * Custom providers must implement their actual isolation guarantee rather * than inheriting an ordinary transaction with unknown isolation. */ - async readSnapshot(_read: (trx: TrxToken) => Promise): Promise { - throw new WERR_NOT_IMPLEMENTED('Coherent wallet source snapshots are not supported by this provider') + readSnapshot(_read: (trx: TrxToken) => Promise): Promise { + return Promise.reject( + new WERR_NOT_IMPLEMENTED('Coherent wallet source snapshots are not supported by this provider') + ) } protected supportsActionBatchPersistence(): boolean { diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts new file mode 100644 index 000000000..38496736a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts @@ -0,0 +1,85 @@ +import { knex, type Knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' + +function mysqlFixture(failAt?: string) { + const db = knex({ client: 'mysql2' }) + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: db }) + const events: string[] = [] + const failure = new Error('synthetic database failure') + const connection = { + destroy() { + events.push('CLOSE') + }, + query( + query: { sql: string }, + _bindings: unknown, + callback: (error: Error | null, rows?: unknown[], fields?: unknown[]) => void + ) { + const sql = query.sql.toUpperCase().replace(/;$/, '') + events.push(sql) + if (sql === failAt) return callback(failure) + // Exercise the actual installed Knex transaction builder against MySQL's + // comma-separated characteristic grammar. The combined options emitted + // invalid syntax before this regression was found with real MySQL 8.4. + if (sql.includes('REPEATABLE READ READ ONLY')) return callback(new Error('invalid MySQL syntax')) + callback(null, [], []) + } + } + const acquire = jest.spyOn(db.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(db.client, 'releaseConnection').mockImplementation(() => { + events.push('RELEASE') + return Promise.resolve() + }) + jest.spyOn(storage, 'makeAvailable').mockResolvedValue({ + created_at: new Date(0), + updated_at: new Date(0), + storageIdentityKey: 'synthetic-storage', + storageName: 'synthetic MySQL', + chain: 'test', + dbtype: 'MySQL', + maxOutputScript: 1024 + }) + return { storage, db, events, failure, acquire } +} + +test('MySQL snapshot uses one reserved connection and valid next-transaction characteristics', async () => { + const fixture = mysqlFixture() + try { + const result = await fixture.storage.readSnapshot(async trx => { + await (trx as Knex.Transaction).raw('SELECT 1') + return 17 + }) + expect(result).toBe(17) + expect(fixture.acquire).toHaveBeenCalledTimes(1) + expect(fixture.events).toEqual([ + 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY', + 'BEGIN', + 'SELECT 1', + 'COMMIT', + 'RELEASE' + ]) + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } +}) + +test.each(['SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY', 'BEGIN', 'SELECT 1', 'COMMIT'])( + 'MySQL snapshot discards a failed connection before returning it to the pool (%s)', + async failAt => { + const fixture = mysqlFixture(failAt) + try { + const capture = jest.fn((trx: object) => (trx as Knex.Transaction).raw('SELECT 1').then(() => undefined)) + await expect(fixture.storage.readSnapshot(capture)).rejects.toBe(fixture.failure) + await new Promise(resolve => setImmediate(resolve)) + expect(fixture.acquire).toHaveBeenCalledTimes(1) + expect(fixture.events.slice(-2)).toEqual(['CLOSE', 'RELEASE']) + if (failAt === 'SELECT 1') expect(fixture.events).toContain('ROLLBACK') + if (failAt === 'BEGIN' || failAt.startsWith('SET TRANSACTION')) expect(capture).not.toHaveBeenCalled() + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } + } +) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 1e0c761bc..fa93ead5d 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -77,7 +77,9 @@ capture instead of being filtered out of the archive. This checkpoint is only part of S2/P1. It does not yet implement keysets, immutable retained snapshots, streaming, bounded push/backup work or staged restore. IndexedDB writers wait during capture and the legacy helpers still materialize -the document. MySQL's explicit repeatable-read path needs live qualification. +the document. A local MySQL 8.4.11 fixture confirms repeatable-read capture under +an independent writer, read-only enforcement, unchanged session defaults and +failure cleanup. Deployed MySQL/PXC behavior is not qualified by that fixture. For every checkpoint record the exact source revision, commands, fixture and platform, measured result, compatibility result and remaining limitation. Link From ca3197328e976c0e3635636dd187972b077250a7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 19:17:00 -0700 Subject: [PATCH 032/127] Add bounded retained SQL read views for wallet sync --- docs/guides/wallet-data-portability.md | 11 +- docs/guides/wallet-sync-reliability.md | 63 +++- docs/reference/package-api-migrations.md | 84 ++--- governance/mutation-testing/policy.json | 10 + governance/mutation-testing/targets.mjs | 31 ++ governance/package-release-notes.json | 12 +- governance/test-quality/policy.json | 13 + packages/wallet/wallet-toolbox/CHANGELOG.md | 8 + packages/wallet/wallet-toolbox/README.md | 8 + .../wallet/wallet-toolbox/client/README.md | 7 + .../wallet/wallet-toolbox/mobile/README.md | 7 + packages/wallet/wallet-toolbox/package.json | 2 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 71 +++- .../src/storage/StorageProvider.ts | 11 + .../wallet-toolbox/src/storage/index.all.ts | 2 + .../src/storage/index.client.ts | 2 + .../src/storage/index.mobile.ts | 2 + .../remoting/__test/StorageServerRpc.test.ts | 18 + .../RetainedReadSnapshot.property.test.ts | 168 +++++++++ .../snapshot/RetainedReadSnapshot.test.ts | 340 ++++++++++++++++++ .../storage/snapshot/RetainedReadSnapshot.ts | 168 +++++++++ .../StorageKnex.retainedSnapshot.test.ts | 251 +++++++++++++ scripts/test-governance.test.mjs | 4 +- specs/wallet/sync-portability-program.md | 15 +- 24 files changed, 1230 insertions(+), 78 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts diff --git a/docs/guides/wallet-data-portability.md b/docs/guides/wallet-data-portability.md index ae539302f..7de935aee 100644 --- a/docs/guides/wallet-data-portability.md +++ b/docs/guides/wallet-data-portability.md @@ -3,8 +3,8 @@ id: wallet-data-portability title: 'BRC-38/39 Wallet Data Portability' kind: guide version: '1.0.0' -last_updated: '2026-09-29' -last_verified: '2026-09-29' +last_updated: '2026-09-30' +last_verified: '2026-09-30' review_cadence_days: 30 status: stable tags: [wallet, backup, interoperability, brc38, brc39] @@ -59,6 +59,13 @@ object properties in a detached copy, retaining false, zero, empty strings and every array position. Required values remain subject to validation. A null array entry is rejected rather than dropped. The source records are not rewritten. +The candidate also adds a local SQL `openReadSnapshot` lifetime with explicit +close/cancellation/expiry and one read at a time. It retains a pinned transaction +across idle periods and occupies a pool connection until physical cleanup. +IndexedDB and remote clients do not gain this capability. The existing export +helpers continue to use their scoped capture path; see +[retained SQL view limits](wallet-sync-reliability.md#retained-local-sql-read-views-unpublished-candidate). + This is an intermediate source candidate, not a released streaming export API. The materialized helpers below still allocate the full document/file. Remote snapshot handles, bounded immutable paging, streaming files, staged recovery and diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index a6ba7c86e..471c59d9b 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -3,8 +3,8 @@ id: wallet-sync-reliability title: 'Resumable wallet synchronization and proof recovery' kind: guide version: '1.0.0' -last_updated: '2026-09-23' -last_verified: '2026-09-23' +last_updated: '2026-09-30' +last_verified: '2026-09-30' review_cadence_days: 30 status: beta tags: [wallet, sync, storage, performance] @@ -126,6 +126,39 @@ snapshot or authorization to activate a new primary. Existing BRC-38/BRC-39 export/import contracts are unchanged. Issue #544 remains open for coherent remote snapshots, streamed archives and consumer adoption. +## Retained local SQL read views (unpublished candidate) + +The 2.15 candidate adds `StorageKnex.openReadSnapshot({ lifetimeMs, signal })`. +Opening pins a SQLite or MySQL read view before returning. Its `read(callback)` +method reuses that transaction across idle periods; pass the supplied token to +**every** query and await every query before returning. Each provider admits one +retained view and each view admits one read at a time. Concurrent/nested reads +reject immediately. These local methods are excluded from the storage RPC +allowlist; the handle is neither an authenticated remote export nor a profile +boundary. + +The lifetime defaults to five minutes, includes acquisition, and accepts integer +milliseconds from 1 through 3,600,000. Both monotonic and wall clocks enforce +expiry. Close, cancellation and expiry stop new reads and discard late results. +Await `close()` or `closed` to observe physical transaction/connection cleanup; +a read failure invalidates the view and rolls back. Configure database query and +connection deadlines separately: cancelling the view cannot interrupt a hung +driver call or user callback. Do not perform writes, peer/file I/O, progress +callbacks, or await `close()` inside a read callback. + +A view occupies one pool connection until cleanup. With a one-connection pool, +other work must wait; the API does not create an independent writer. SQLite +concurrent-write qualification uses WAL and a separate connection. MySQL uses a +read-only repeatable-read transaction without changing pooled session defaults. +SQLite relies on the trusted callback's read-only contract. `StorageIdb` and +unsupported providers explicitly refuse retained views; their existing scoped +snapshot behavior is unchanged. Process exit loses the view. + +This primitive does not yet provide a bounded paging API, durable cursor, +remote handle, concurrent IndexedDB snapshot, or streaming archive. Ordinary +push/backup loops still require the scheduling and checkpoint work below. No +persisted schema, legacy index order or wire encoding changes in this checkpoint. + ## Next-stage design checkpoint (not implemented) A future source-snapshot capability should negotiate a versioned contract @@ -189,14 +222,14 @@ Foreground latency starts when its timer callback runs. Event-loop delay is reported separately so synchronous CPU stalls remain visible. The exclusive control has very few completed foreground samples because it holds the queue. -| Backend / mode | Full copy ms | Foreground samples | Foreground p50 / p95 / p99 ms | Event-loop p95 / p99 ms | Pages | -| --- | ---: | ---: | --- | --- | ---: | -| Chromium IndexedDB / exclusive | 4027.1 | 1 | 4021.50 / 4021.50 / 4021.50 | 1.30 / 1.60 | 45 | -| Chromium IndexedDB / paged | 4110.0 | 384 | 0.90 / 25.90 / 132.60 | 1.30 / 1.70 | 45 | -| sqlite / exclusive | 2368.7 | 1 | 2342.43 / 2342.43 / 2342.43 | 85.12 / 106.86 | 45 | -| sqlite / paged | 2373.8 | 133 | 0.17 / 0.32 / 0.40 | 84.86 / 109.29 | 45 | -| http / exclusive | 26184.7 | 5 | 0.25 / 26092.86 / 26092.86 | 250.70 / 271.70 | 47 | -| http / paged | 26394.8 | 319 | 0.24 / 0.35 / 0.40 | 255.93 / 271.98 | 47 | +| Backend / mode | Full copy ms | Foreground samples | Foreground p50 / p95 / p99 ms | Event-loop p95 / p99 ms | Pages | +| ------------------------------ | -----------: | -----------------: | ----------------------------- | ----------------------- | ----: | +| Chromium IndexedDB / exclusive | 4027.1 | 1 | 4021.50 / 4021.50 / 4021.50 | 1.30 / 1.60 | 45 | +| Chromium IndexedDB / paged | 4110.0 | 384 | 0.90 / 25.90 / 132.60 | 1.30 / 1.70 | 45 | +| sqlite / exclusive | 2368.7 | 1 | 2342.43 / 2342.43 / 2342.43 | 85.12 / 106.86 | 45 | +| sqlite / paged | 2373.8 | 133 | 0.17 / 0.32 / 0.40 | 84.86 / 109.29 | 45 | +| http / exclusive | 26184.7 | 5 | 0.25 / 26092.86 / 26092.86 | 250.70 / 271.70 | 47 | +| http / paged | 26394.8 | 319 | 0.24 / 0.35 / 0.40 | 255.93 / 271.98 | 47 | Native browser peak JS heap was 79.8 MB exclusive and 56.3 MB paged. SQLite full-copy CPU was 1.86/1.86 seconds and sampled RSS growth 127.6/60.1 MB. @@ -230,11 +263,11 @@ MySQL environment and were not executed by the default local invocation. After integrating the SDK compatibility repair, repeat measurements with official Node 24.18.0 and the same sequential fixture passed the acceptance gates: -| Backend | Full-copy ms, exclusive / paged | Foreground p95 ms, exclusive / paged | Paged event-loop p95 ms | -| --- | ---: | ---: | ---: | -| Native Chromium IndexedDB | 3943.6 / 3971.1 | 3938.80 / 24.50 | 1.00 | -| SQLite | 2270.1 / 2238.5 | 2247.25 / 0.17 | 82.23 | -| Authenticated HTTP to SQLite | 20982.5 / 21004.3 | 20901.51 / 0.31 | 195.69 | +| Backend | Full-copy ms, exclusive / paged | Foreground p95 ms, exclusive / paged | Paged event-loop p95 ms | +| ---------------------------- | ------------------------------: | -----------------------------------: | ----------------------: | +| Native Chromium IndexedDB | 3943.6 / 3971.1 | 3938.80 / 24.50 | 1.00 | +| SQLite | 2270.1 / 2238.5 | 2247.25 / 0.17 | 82.23 | +| Authenticated HTTP to SQLite | 20982.5 / 21004.3 | 20901.51 / 0.31 | 195.69 | These runs include shared reader/writer admission with unchanged authorization ordering and direct entity normalization. Native peak heap was 79.8 MB exclusive diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index cf94f7505..e342a8c97 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC and do not yet change ordinary backup scheduling. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -537,8 +537,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -549,8 +549,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 88529f27b..7df506c4f 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -57,6 +57,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-retained-snapshot", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts", + "risk": "critical", + "boundary": "Wallet retained database-view admission, expiry, cancellation and physical cleanup ownership", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "overlay-linkage", "manifest": "packages/overlays/topics/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index e59846b79..a1bd1fdd9 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -230,6 +230,37 @@ export function buildMutationTargets(repositoryRoot) { } ) }, + 'wallet-retained-snapshot': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts', + mutate: [ + 'src/storage/snapshot/RetainedReadSnapshot.ts', + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override supportsRetainedReadSnapshot(): boolean', + 'private async readMySQLSnapshot' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageProvider.ts', + 'supportsRetainedReadSnapshot(): boolean', + 'protected supportsActionBatchPersistence(): boolean' + ) + ], + ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/*.test.ts'], { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + }) + }, 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', manifest: 'packages/overlays/topics/package.json', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index c99a95f3e..0b89379d6 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC and do not yet change ordinary backup scheduling.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation.", + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes." + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation.", + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged." }, { "name": "create-bsv-app", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 318c95de6..290de3e21 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -95,6 +95,19 @@ "Trailing bytes are rejected." ] }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet retained database-view admission, expiry, cancellation and physical cleanup ownership", + "target": "Randomized read, cancellation, close and expiry schedules with an independent admission and cleanup model", + "invariants": [ + "At most one database read is admitted and concurrent or nested reads do not queue.", + "Closing, cancellation and expiry reject later admission and discard late successful results.", + "Capacity remains occupied until physical reads and transaction cleanup settle.", + "Every completed lifecycle removes its expiry timer and rejects further reads." + ] + }, { "path": "packages/overlays/topics/src/mandala/__tests/types.property.test.ts", "manifest": "packages/overlays/topics/package.json", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index a94a76265..3907a6540 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,14 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add local retained SQLite/MySQL views with explicit lifetime, cancellation and + cleanup ownership. Opening pins the transaction before the first consumer read; + one view per provider and one read per view prevent unbounded admission. Closing + discards late results and awaits physical cleanup, including failed reads. + Driver/query deadlines remain separate. Each view occupies a pool connection; + IndexedDB and RPC explicitly remain unsupported for retention. This is a paging + prerequisite, without changing ordinary backup locks, schemas or wire formats. + - Capture BRC-38 source metadata and every table from one provider read view. Custom providers opt in with `supportsReadSnapshot` and `readSnapshot`. The additive `requireSnapshot` export option refuses unsupported views; old diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 7f796269a..69921244f 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -39,6 +39,14 @@ retain their documented caller-quiesced fallback. Recognized optional nullable JSON fields are omitted in a detached archive copy; array entries and meaningful falsy values are preserved. The helpers still materialize the full document/file, and IndexedDB writers wait during capture. +SQL providers also expose `supportsRetainedReadSnapshot` / `openReadSnapshot` +for a local view held across idle reads, with one view per provider, one read at +a time, and bounded lifetime/cancellation. Await `closed`/`close()` for physical +cleanup. The view occupies a connection; driver deadlines remain separate and a +single-connection pool cannot serve other work until release. IndexedDB and +remote RPC do not expose retained views. This does not yet yield ordinary backup +work or add a bounded paging API. See the +[retained view contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#retained-local-sql-read-views-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/client/README.md b/packages/wallet/wallet-toolbox/client/README.md index 116795d2b..9766ee175 100644 --- a/packages/wallet/wallet-toolbox/client/README.md +++ b/packages/wallet/wallet-toolbox/client/README.md @@ -46,6 +46,13 @@ data copy and test recovery on a clean profile. Read [Wallet backup and recovery](https://bsv-blockchain.github.io/ts-stack/guides/wallet-backup-recovery/), [BRC-38/39 integration](https://bsv-blockchain.github.io/ts-stack/guides/wallet-data-portability/) and the [recovery checklist](https://bsv-blockchain.github.io/ts-stack/guides/wallet-recovery-drill/). +The unpublished candidate exports `RetainedReadSnapshot` and +`RetainedReadSnapshotOptions` types. They do not add a retained IndexedDB, +remote, or native mobile implementation. Unsupported local providers report +`supportsRetainedReadSnapshot() === false` and refuse `openReadSnapshot()`. +The SQL implementation and its connection/lifetime limits are described in the +[sync guide](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#retained-local-sql-read-views-unpublished-candidate). + Portable helpers require a concrete local `StorageProvider`; a remote client is not one. Qualify the local-copy path and device memory limits before adding export/import UI. diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 5f45d06eb..2a4dc89d6 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -46,6 +46,13 @@ Test the independent key and data recovery paths on a replacement device. Read [Wallet backup and recovery](https://bsv-blockchain.github.io/ts-stack/guides/wallet-backup-recovery/), [BRC-38/39 integration](https://bsv-blockchain.github.io/ts-stack/guides/wallet-data-portability/) and the [recovery checklist](https://bsv-blockchain.github.io/ts-stack/guides/wallet-recovery-drill/). +The unpublished candidate exports `RetainedReadSnapshot` and +`RetainedReadSnapshotOptions` types. They do not add a retained IndexedDB, +remote, or native mobile implementation. Unsupported local providers report +`supportsRetainedReadSnapshot() === false` and refuse `openReadSnapshot()`. +The SQL implementation and its connection/lifetime limits are described in the +[sync guide](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#retained-local-sql-read-views-unpublished-candidate). + Portable helpers require a concrete local `StorageProvider`; a remote client is not one. Qualify the local-copy path and device memory limits before adding export/import UI. diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 2911ed3a2..eea1569ba 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 12b2d642a..a709b38ed 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,3 +1,9 @@ +import { + retainReadSnapshot, + type RetainedReadSnapshot, + type RetainedReadSnapshotLifetime, + type RetainedReadSnapshotOptions +} from './snapshot/RetainedReadSnapshot' import { recoveredProofUpdate } from './methods/validateSyncProof' import { type ValidListActionsArgs, type ValidListOutputsArgs } from '@bsv/sdk/wallet/validationHelpers' import { ListActionsResult, ListOutputsResult, TelemetrySpan } from '@bsv/sdk' @@ -61,7 +67,13 @@ import { SyncChunkTotals, WalletStorageProvider } from '../sdk/WalletStorage.interfaces' -import { WERR_INTERNAL, WERR_INVALID_PARAMETER, WERR_NOT_IMPLEMENTED, WERR_UNAUTHORIZED } from '../sdk/WERR_errors' +import { + WERR_INTERNAL, + WERR_INVALID_OPERATION, + WERR_INVALID_PARAMETER, + WERR_NOT_IMPLEMENTED, + WERR_UNAUTHORIZED +} from '../sdk/WERR_errors' import { verifyId, verifyOne, verifyOneOrNone } from '../utility/utilityHelpers' import { EntityTimeStamp, TransactionStatus } from '../sdk/types' @@ -106,6 +118,8 @@ interface PreparedBeefMetadata { export class StorageKnex extends StorageProvider implements WalletStorageProvider { knex: Knex + private retainedReadSnapshot?: RetainedReadSnapshotLifetime + private retainedReadSnapshotsStopped = false readonly preparedBeefPolicy: PreparedBeefPolicy private readonly preparedBeefCoordinator: PreparedBeefCoordinator private readonly preparedBeefReadSuspensions = new Set() @@ -188,6 +202,43 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide throw new WERR_NOT_IMPLEMENTED('Coherent wallet source snapshots require SQLite or MySQL isolation') } + override supportsRetainedReadSnapshot(): boolean { + return this.supportsReadSnapshot() + } + + /** One retained local transaction per provider; it occupies one pool connection until physical cleanup. */ + override async openReadSnapshot(options: RetainedReadSnapshotOptions = {}): Promise { + if (this.retainedReadSnapshotsStopped) { + throw new WERR_INVALID_OPERATION('Retained read snapshots are unavailable after provider destruction begins') + } + if (!this.supportsRetainedReadSnapshot()) return await super.openReadSnapshot(options) + if (this.retainedReadSnapshot !== undefined) { + throw new WERR_INVALID_OPERATION('This provider already has a retained read snapshot opening or active') + } + const lifetime = retainReadSnapshot( + read => this.readSnapshot(read), + async trx => { + // Pin SQLite's deferred read view before opening resolves. MySQL also + // establishes its repeatable-read snapshot on this first data read. + await this.readSettings(trx) + }, + options + ) + this.retainedReadSnapshot = lifetime + const release = (): void => { + if (this.retainedReadSnapshot === lifetime) this.retainedReadSnapshot = undefined + } + void lifetime.closed.then(release, release) + return await lifetime.opened + } + + private async stopRetainedReadSnapshots(): Promise { + // Fence admission before any asynchronous cleanup can yield. A view whose + // close releases the capacity slot must not permit reopening during destroy. + this.retainedReadSnapshotsStopped = true + await this.retainedReadSnapshot?.close() + } + private async readMySQLSnapshot(read: (trx: TrxToken) => Promise): Promise { const client = this.knex.client const connection = await client.acquireConnection() @@ -1670,13 +1721,17 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } override async destroy(): Promise { - await this.stopPreparedBeefTasks() - this.knex.off('query', this.onQuery) - this.knex.off('query-response', this.onQueryResponse) - this.knex.off('query-error', this.onQueryError) - for (const span of this.querySpans.values()) span.end({ status: 'cancelled' }) - this.querySpans.clear() - await this.knex?.destroy() + try { + await this.stopRetainedReadSnapshots() + } finally { + await this.stopPreparedBeefTasks() + this.knex.off('query', this.onQuery) + this.knex.off('query-response', this.onQueryResponse) + this.knex.off('query-error', this.onQueryError) + for (const span of this.querySpans.values()) span.end({ status: 'cancelled' }) + this.querySpans.clear() + await this.knex?.destroy() + } } override async migrate(storageName: string, storageIdentityKey: string): Promise { diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index ae347a939..b090075de 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -1,3 +1,4 @@ +import type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' import { runInSeries } from '../utility/runInSeries' import { findProofRecords, mapProofWork } from './methods/proofWork' import { snapshotSyncPage } from './sync/snapshotSyncPage' @@ -533,6 +534,16 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal ) } + /** Local transaction retention only; no remote/profile export capability is implied. */ + supportsRetainedReadSnapshot(): boolean { + return false + } + + /** Older and auto-closing transaction providers must explicitly refuse retention. */ + openReadSnapshot(_options?: RetainedReadSnapshotOptions): Promise { + return Promise.reject(new WERR_NOT_IMPLEMENTED('Retained read snapshots are not supported by this provider')) + } + protected supportsActionBatchPersistence(): boolean { return false } diff --git a/packages/wallet/wallet-toolbox/src/storage/index.all.ts b/packages/wallet/wallet-toolbox/src/storage/index.all.ts index 2acc23c06..ceb147324 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.all.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.all.ts @@ -20,3 +20,5 @@ export * from './schema/tables/index' export * from './schema/entities/index' export * as sync from './sync' export * from './portable' + +export type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' diff --git a/packages/wallet/wallet-toolbox/src/storage/index.client.ts b/packages/wallet/wallet-toolbox/src/storage/index.client.ts index 799fac3b6..822df52b6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.client.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.client.ts @@ -11,3 +11,5 @@ export * from './methods/ListActionsSpecOp' export * from './methods/ListOutputsSpecOp' export * from './methods/managedChange' export * from './methods/managedChangePolicy' + +export type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' diff --git a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts index 7cfc44136..60cc64fe3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts @@ -9,3 +9,5 @@ export * from './portable' export * from './methods/ListActionsSpecOp' export * from './methods/ListOutputsSpecOp' export * from './methods/managedChangePolicy' + +export type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts index f39dc4964..914c793b7 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts @@ -409,6 +409,24 @@ describe('StorageServer JSON-RPC boundary', () => { }) }) + test.each(['readSnapshot', 'supportsReadSnapshot', 'openReadSnapshot', 'supportsRetainedReadSnapshot'])( + 'keeps local snapshot method %s outside the authenticated RPC surface', + async method => { + const handler = jest.fn(async () => undefined) + const server = makeServer({ [method]: handler }) + const response = makeResponse() + await invoke( + server, + 'handleRpcRequest', + makeRequest({ jsonrpc: '2.0', method, params: [], id: 1 }), + response.response + ) + expect(response.statusCode).toBe(400) + expect(response.body).toMatchObject({ error: { code: -32601, message: `Method not found: ${method}` } }) + expect(handler).not.toHaveBeenCalled() + } + ) + test('redacts internal storage failures from JSON-RPC wallet errors', async () => { const server = makeServer( { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts new file mode 100644 index 000000000..fb8e8f5c0 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts @@ -0,0 +1,168 @@ +import fc from 'fast-check' +import { runInSeries } from '../../utility/runInSeries' +import { retainReadSnapshot } from './RetainedReadSnapshot' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +function gate() { + let resolve!: () => void + const promise = new Promise(yes => { + resolve = yes + }) + return { promise, resolve } +} + +type Outcome = { ok: true; value: number } | { ok: false; error: unknown } +interface PendingRead { + value: number + release: () => void + outcome: Promise + settled: () => boolean +} + +const operations = fc.array(fc.constantFrom('read', 'settle', 'close', 'cancel', 'advance'), { + minLength: 1, + maxLength: 40 +}) + +afterEach(() => { + jest.useRealTimers() +}) + +test('random schedules preserve single-read admission, late-result rejection and physical cleanup ownership', async () => { + jest.useFakeTimers() + await fc.assert( + fc.asyncProperty(operations, fc.integer({ min: 1, max: 1000 }), async (steps, lifetimeMs) => { + const controller = new AbortController() + const cleanup = gate() + const token = { synthetic: true } + let physicalReads = 0 + let enteredReads = 0 + let leftCallback = false + let released = false + let closed = false + let pending: PendingRead | undefined + let expectedReads = 0 + let alive = true + let elapsed = 0 + const lifetime = retainReadSnapshot( + async read => { + try { + await read(token) + } finally { + leftCallback = true + await cleanup.promise + released = true + } + }, + async received => { + expect(received).toBe(token) + }, + { signal: controller.signal, lifetimeMs } + ) + void lifetime.closed.catch(() => undefined) + const view = await lifetime.opened.catch(async error => { + cleanup.resolve() + await lifetime.closed.catch(() => undefined) + throw error + }) + void view.closed.then( + () => { + closed = true + }, + () => undefined + ) + + const finishRead = async (): Promise => { + if (pending === undefined) return + const active = pending + active.release() + const outcome = await active.outcome + if (alive) expect(outcome).toEqual({ ok: true, value: active.value }) + else expect(outcome).toEqual({ ok: false, error: expect.any(Error) }) + pending = undefined + expect(physicalReads).toBe(0) + } + try { + // Each generated operation awaits the previous operation's observations; + // the database read itself remains independently held until `settle`. + await runInSeries(steps, async step => { + if (step === 'read') { + if (!alive || pending !== undefined) { + const extra = jest.fn(async () => 1) + await expect(view.read(extra)).rejects.toBeInstanceOf(Error) + expect(extra).not.toHaveBeenCalled() + } else { + const held = gate() + const value = ++expectedReads + let settled = false + const outcome: Promise = view + .read(async received => { + expect(received).toBe(token) + physicalReads++ + enteredReads++ + expect(physicalReads).toBe(1) + await held.promise + physicalReads-- + return value + }) + .then( + result => { + settled = true + return { ok: true, value: result } + }, + error => { + settled = true + return { ok: false, error } + } + ) + pending = { value, release: held.resolve, outcome, settled: () => settled } + } + } else if (step === 'settle') { + await finishRead() + } else if (step === 'advance') { + elapsed += 250 + if (elapsed >= lifetimeMs) alive = false + jest.advanceTimersByTime(250) + } else { + alive = false + if (step === 'cancel') controller.abort() + else void view.close().catch(() => undefined) + } + // Observe both read continuation and provider callback handoffs. + await Promise.resolve() + await Promise.resolve() + expect(view.isOpen).toBe(alive) + expect(enteredReads).toBe(expectedReads) + expect(released).toBe(false) + expect(closed).toBe(false) + if (pending !== undefined) { + expect(pending.settled()).toBe(false) + expect(physicalReads).toBe(1) + expect(leftCallback).toBe(false) + } + }) + await finishRead() + } finally { + const closing = view.close() + pending?.release() + await pending?.outcome + cleanup.resolve() + await closing + } + expect(physicalReads).toBe(0) + expect(released).toBe(true) + expect(closed).toBe(true) + expect(view.isOpen).toBe(false) + expect(jest.getTimerCount()).toBe(0) + }) + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.test.ts new file mode 100644 index 000000000..e02eca06c --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.test.ts @@ -0,0 +1,340 @@ +import type { TrxToken } from '../../sdk/WalletStorage.interfaces' +import { retainReadSnapshot, type RetainedReadSnapshotLifetime } from './RetainedReadSnapshot' + +function gate() { + let resolve!: () => void + const promise = new Promise(yes => { + resolve = yes + }) + return { promise, resolve } +} + +const lifetimes: RetainedReadSnapshotLifetime[] = [] +const gates: Array> = [] +const expectedFailures = new Set() + +function hold() { + const value = gate() + gates.push(value) + return value +} + +function fixture( + options: Parameters[2] = {}, + controls: { + acquire?: Promise + initialize?: Promise + cleanup?: Promise + initializeError?: Error + beginError?: Error + cleanupError?: Error + } = {} +) { + const token: TrxToken = { synthetic: true } + const events: string[] = [] + const initializing = gate() + const run = jest.fn(async (read: (trx: TrxToken) => Promise) => { + await controls.acquire + events.push('acquired') + try { + if (controls.beginError) throw controls.beginError + await read(token) + events.push('commit') + } finally { + await controls.cleanup + events.push('released') + } + if (controls.cleanupError) throw controls.cleanupError + }) + const initialize = jest.fn(async (trx: TrxToken) => { + expect(trx).toBe(token) + events.push('initialize') + initializing.resolve() + await controls.initialize + if (controls.initializeError) throw controls.initializeError + }) + const lifetime = retainReadSnapshot(run, initialize, options) + // Preserve the promise for assertions while ensuring failed-test cleanup + // cannot create an unrelated unhandled rejection from a still-opening view. + void lifetime.opened.catch(() => undefined) + void lifetime.closed.catch(() => undefined) + lifetimes.push(lifetime) + return { token, events, run, initialize, initializing: initializing.promise, lifetime } +} + +afterEach(async () => { + gates.splice(0).forEach(value => value.resolve()) + const results = await Promise.allSettled(lifetimes.splice(0).map(value => value.close())) + for (const result of results) { + if (result.status === 'rejected') expect(expectedFailures.has(result.reason)).toBe(true) + } + expectedFailures.clear() + jest.useRealTimers() + jest.restoreAllMocks() +}) + +test('establishes the read view before opening and retains the exact token across idle boundaries', async () => { + const initialize = hold() + const f = fixture({}, { initialize: initialize.promise }) + let opened = false + void f.lifetime.opened.then( + () => { + opened = true + }, + () => undefined + ) + await Promise.resolve() + expect(opened).toBe(false) + initialize.resolve() + const view = await f.lifetime.opened + expect(f.events).toEqual(['acquired', 'initialize']) + expect(view.isOpen).toBe(true) + expect(await view.read(async token => token)).toBe(f.token) + await new Promise(resolve => setImmediate(resolve)) + expect(await view.read(async token => token)).toBe(f.token) + await Promise.all([view.close(), view.close(), view.closed]) + expect(view.isOpen).toBe(false) + expect(f.events).toEqual(['acquired', 'initialize', 'commit', 'released']) + await expect(view.read(async () => 1)).rejects.toThrow('closed') +}) + +test('refuses concurrent and nested reads without queuing or invoking them', async () => { + const f = fixture() + const view = await f.lifetime.opened + const releaseRead = hold() + const first = view.read(async () => await releaseRead.promise) + void first.catch(() => undefined) + const extra = jest.fn(async () => 2) + await expect(view.read(extra)).rejects.toThrow('read in flight') + expect(extra).not.toHaveBeenCalled() + releaseRead.resolve() + await first + await view.read(async () => { + await expect(view.read(extra)).rejects.toThrow('read in flight') + }) + expect(extra).not.toHaveBeenCalled() +}) + +test.each(['close', 'abort', 'expiry'] as const)( + '%s discards a late read and retains capacity until physical read and cleanup both settle', + async action => { + jest.useFakeTimers() + const controller = new AbortController() + const releaseRead = hold() + const cleanup = hold() + const f = fixture({ lifetimeMs: 100, signal: controller.signal }, { cleanup: cleanup.promise }) + const view = await f.lifetime.opened + let released = false + void view.closed.then( + () => { + released = true + }, + () => undefined + ) + const pending = view.read(async () => { + await releaseRead.promise + return 'late bytes' + }) + const outcome = pending.catch(error => error) + if (action === 'close') void view.close().catch(() => undefined) + if (action === 'abort') controller.abort() + if (action === 'expiry') await jest.advanceTimersByTimeAsync(100) + expect(view.isOpen).toBe(false) + expect(released).toBe(false) + expect(f.events).toEqual(['acquired', 'initialize']) + const another = jest.fn(async () => 2) + await expect(view.read(another)).rejects.toThrow(/closed|cancelled|expired/) + expect(another).not.toHaveBeenCalled() + releaseRead.resolve() + expect(await outcome).toBeInstanceOf(Error) + await Promise.resolve() + expect(released).toBe(false) + cleanup.resolve() + await view.closed + expect(f.events).toEqual(['acquired', 'initialize', 'commit', 'released']) + expect(released).toBe(true) + expect(jest.getTimerCount()).toBe(0) + } +) + +test.each(['acquire', 'initialize'] as const)( + 'cancelling during %s does not release an unsettled transaction', + async phase => { + const controller = new AbortController() + const held = hold() + const f = fixture({ signal: controller.signal }, { [phase]: held.promise }) + const opening = f.lifetime.opened.catch(error => error) + let closed = false + void f.lifetime.closed.then( + () => { + closed = true + }, + () => undefined + ) + if (phase === 'initialize') await f.initializing + else await Promise.resolve() + controller.abort() + expect(await opening).toBeInstanceOf(Error) + expect(closed).toBe(false) + held.resolve() + await f.lifetime.closed + expect(closed).toBe(true) + if (phase === 'acquire') expect(f.initialize).not.toHaveBeenCalled() + expect(f.events.at(-1)).toBe('released') + } +) + +test('pre-cancelled views never acquire a connection', async () => { + const controller = new AbortController() + controller.abort() + const f = fixture({ signal: controller.signal }) + await expect(f.lifetime.opened).rejects.toThrow('cancelled') + await f.lifetime.closed + expect(f.run).not.toHaveBeenCalled() +}) + +test.each([0, -1, 0.5, NaN, Infinity, 3_600_001])('refuses invalid lifetime %s before acquisition', lifetimeMs => { + const run = jest.fn() + expect(() => { + const lifetime = retainReadSnapshot(run, async () => undefined, { lifetimeMs }) + void lifetime.opened.catch(() => undefined) + void lifetime.closed.catch(() => undefined) + lifetimes.push(lifetime) + }).toThrow('an integer from 1 to 3600000') + expect(run).not.toHaveBeenCalled() +}) + +test('monotonic expiry rejects a read even before its timer callback executes', async () => { + const f = fixture({ lifetimeMs: 100 }) + const view = await f.lifetime.opened + jest.spyOn(performance, 'now').mockReturnValue(Number.MAX_SAFE_INTEGER) + const read = jest.fn(async () => 1) + await expect(view.read(read)).rejects.toThrow('expired') + expect(read).not.toHaveBeenCalled() + await view.closed +}) + +test.each(['begin', 'initialize', 'read', 'cleanup'] as const)( + 'preserves %s errors and always finishes physical cleanup', + async phase => { + const error = new Error(`synthetic ${phase} failure`) + expectedFailures.add(error) + const f = fixture( + {}, + { + initializeError: phase === 'initialize' ? error : undefined, + beginError: phase === 'begin' ? error : undefined, + cleanupError: phase === 'cleanup' ? error : undefined + } + ) + if (phase === 'begin' || phase === 'initialize') { + await expect(f.lifetime.opened).rejects.toBe(error) + } else { + const view = await f.lifetime.opened + if (phase === 'read') { + await expect( + view.read(() => { + throw error + }) + ).rejects.toBe(error) + } else { + await expect(view.close()).rejects.toBe(error) + } + } + await expect(f.lifetime.closed).rejects.toBe(error) + expect(f.events.at(-1)).toBe('released') + } +) + +test('rejects an invalid callback without poisoning the retained view', async () => { + const f = fixture() + const view = await f.lifetime.opened + await expect(view.read(undefined as never)).rejects.toThrow('callback') + expect(await view.read(async () => 19)).toBe(19) +}) + +test('wall-clock expiry also covers suspension before the monotonic clock or timer advances', async () => { + const f = fixture({ lifetimeMs: 100 }) + const view = await f.lifetime.opened + jest.spyOn(Date, 'now').mockReturnValue(view.expiresAt) + expect(view.isOpen).toBe(false) + await expect(view.read(async () => 1)).rejects.toThrow('expired') + await view.closed +}) + +test.each(['acquire', 'initialize'] as const)( + 'expiry during %s retains capacity through delayed physical cleanup', + async phase => { + jest.useFakeTimers() + const held = hold() + const f = fixture({ lifetimeMs: 100 }, { [phase]: held.promise }) + const opening = f.lifetime.opened.catch(error => error) + let closed = false + void f.lifetime.closed.then( + () => { + closed = true + }, + () => undefined + ) + if (phase === 'initialize') await f.initializing + else await Promise.resolve() + jest.advanceTimersByTime(100) + expect(await opening).toEqual(expect.objectContaining({ message: expect.stringContaining('expired') })) + expect(closed).toBe(false) + held.resolve() + await f.lifetime.closed + expect(closed).toBe(true) + expect(f.events.at(-1)).toBe('released') + } +) + +// Inclusive limits and default lifetime are part of the public admission contract. +test.each([1, 3_600_000, undefined])( + 'accepts the lifetime boundary %s and reports its exact expiry', + async lifetimeMs => { + jest.useFakeTimers() + const start = Date.now() + const f = fixture({ lifetimeMs }) + const view = await f.lifetime.opened + expect(view.expiresAt).toBe(start + (lifetimeMs ?? 300_000)) + expect(view.isOpen).toBe(true) + await view.close() + expect(jest.getTimerCount()).toBe(0) + } +) + +test('monotonic expiry includes its exact boundary despite a backward wall-clock adjustment', async () => { + const start = performance.now() + jest.spyOn(performance, 'now').mockReturnValue(start) + const f = fixture({ lifetimeMs: 100 }) + const view = await f.lifetime.opened + jest.spyOn(Date, 'now').mockReturnValue(view.expiresAt - 500) + jest.spyOn(performance, 'now').mockReturnValue(start + 100) + await expect(view.read(async () => 1)).rejects.toThrow('expired') + await view.closed +}) + +test('keeps the first stop reason across repeated cancellation and close, and removes its signal listener', async () => { + const controller = new AbortController() + const remove = jest.spyOn(controller.signal, 'removeEventListener') + const f = fixture({ signal: controller.signal }) + const view = await f.lifetime.opened + controller.abort() + await view.close() + await expect(view.read(async () => 1)).rejects.toThrow('cancelled') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(remove).toHaveBeenCalledTimes(1) +}) + +test('acquisition failure clears its expiry timer and removes its signal listener', async () => { + jest.useFakeTimers() + const controller = new AbortController() + const remove = jest.spyOn(controller.signal, 'removeEventListener') + const failure = new Error('acquire failed') + expectedFailures.add(failure) + const f = fixture({ signal: controller.signal }, { beginError: failure }) + await expect(f.lifetime.opened).rejects.toBe(failure) + await expect(f.lifetime.closed).rejects.toBe(failure) + expect(jest.getTimerCount()).toBe(0) + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts new file mode 100644 index 000000000..5c8869495 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts @@ -0,0 +1,168 @@ +import type { TrxToken } from '../../sdk/WalletStorage.interfaces' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' + +/** Local database view only. This is not a remote export or authorization token. */ +export interface RetainedReadSnapshotOptions { + /** Total lifetime, including connection acquisition. Defaults to five minutes; at most one hour. */ + lifetimeMs?: number + /** Stops admission and discards late results; physical database work must drain before release. */ + signal?: AbortSignal +} + +export interface RetainedReadSnapshot { + /** Informational wall-clock expiry. Admission also enforces a monotonic lifetime. */ + readonly expiresAt: number + readonly isOpen: boolean + /** Settles only after the provider has finished its transaction and connection cleanup. */ + readonly closed: Promise + /** + * One database read at a time. Await every query before returning; the token + * is valid only inside the callback. Do not + * write, perform peer/file I/O, or await close() from inside this callback. + * Nested/concurrent reads reject rather than creating an unbounded queue. + */ + read: (read: (trx: TrxToken) => Promise) => Promise + /** Stop admission, discard late results, and await physical transaction cleanup. Idempotent. */ + close: () => Promise +} + +interface Deferred { + promise: Promise + resolve: (value: T) => void + reject: (reason: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +/** Provider-owned lifecycle; retain the admission slot until `closed` settles, even if opening is cancelled. */ +export interface RetainedReadSnapshotLifetime { + opened: Promise + closed: Promise + close: () => Promise +} + +export function retainReadSnapshot( + run: (read: (trx: TrxToken) => Promise) => Promise, + establishView: (trx: TrxToken) => Promise, + options: RetainedReadSnapshotOptions = {} +): RetainedReadSnapshotLifetime { + const lifetimeMs = options.lifetimeMs ?? 300_000 + if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs < 1 || lifetimeMs > 3_600_000) { + throw new WERR_INVALID_PARAMETER('lifetimeMs', 'an integer from 1 to 3600000') + } + const { signal } = options + const expiresAt = Date.now() + lifetimeMs + const startedAt = performance.now() + const opened = deferred() + const stopped = deferred() + const closed = deferred() + let token: TrxToken | undefined + let stopReason: WERR_INVALID_OPERATION | undefined + let readFailure: { error: unknown } | undefined + let inFlight: Promise | undefined + let busy = false + let timer: ReturnType | undefined + + // Automatic expiry/failure cleanup remains observed even if a caller only + // awaits read(). The original promise still reports cleanup failure to close(). + void closed.promise.catch(() => undefined) + + const stop = (reason: WERR_INVALID_OPERATION): void => { + if (stopReason !== undefined) return + stopReason = reason + if (timer !== undefined) clearTimeout(timer) + signal?.removeEventListener('abort', abort) + opened.reject(reason) + stopped.resolve() + } + const abort = (): void => stop(new WERR_INVALID_OPERATION('Retained read snapshot was cancelled')) + const checkDeadline = (): void => { + if (Date.now() >= expiresAt || performance.now() - startedAt >= lifetimeMs) { + stop(new WERR_INVALID_OPERATION('Retained read snapshot expired')) + } + } + const assertOpen = (): void => { + checkDeadline() + if (stopReason !== undefined) throw stopReason + } + const close = async (): Promise => { + stop(new WERR_INVALID_OPERATION('Retained read snapshot is closed')) + await closed.promise + } + const snapshot: RetainedReadSnapshot = { + expiresAt, + get isOpen() { + checkDeadline() + // The handle is delivered only after the provider has established its view. + return stopReason === undefined + }, + closed: closed.promise, + close, + async read(read: (trx: TrxToken) => Promise): Promise { + assertOpen() + if (busy) throw new WERR_INVALID_OPERATION('Retained read snapshot already has a read in flight') + if (typeof read !== 'function') throw new WERR_INVALID_PARAMETER('read', 'a database read callback') + busy = true + // Invoke immediately after admission. A synchronous throw is a failed + // physical read too; neither failure nor cancellation frees the slot early. + const pending = (async () => await read(token as TrxToken))() + inFlight = pending.then( + () => undefined, + error => { + readFailure = { error } + stop(new WERR_INVALID_OPERATION('Retained read snapshot read failed')) + } + ) + try { + const result = await pending + assertOpen() + return result + } finally { + busy = false + inFlight = undefined + } + } + } + + const finish = async (): Promise => { + try { + if (stopReason === undefined) { + await run(async trx => { + if (stopReason !== undefined) return + token = trx + await establishView(trx) + checkDeadline() + if (stopReason !== undefined) return + opened.resolve(snapshot) + await stopped.promise + await inFlight + if (readFailure !== undefined) throw readFailure.error + }) + } + closed.resolve() + } catch (error) { + opened.reject(error) + closed.reject(error) + } finally { + stop(new WERR_INVALID_OPERATION('Retained read snapshot is closed')) + token = undefined + } + } + + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted === true) abort() + if (stopReason === undefined) { + timer = setTimeout(() => stop(new WERR_INVALID_OPERATION('Retained read snapshot expired')), lifetimeMs) + } + // Let the provider reserve its capacity slot before any acquisition hook can re-enter. + void Promise.resolve().then(finish) + return { opened: opened.promise, closed: closed.promise, close } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts new file mode 100644 index 000000000..f5d59380b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts @@ -0,0 +1,251 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageIdb } from '../StorageIdb' +import { StorageProvider } from '../StorageProvider' +import { runInSeries } from '../../utility/runInSeries' + +const stores: StorageKnex[] = [] +const directories: string[] = [] +const identity = '02' + '11'.repeat(32) + +async function pair(): Promise<[StorageKnex, StorageKnex]> { + const directory = await mkdtemp(join(tmpdir(), 'retained-wallet-view-')) + directories.push(directory) + const open = () => { + const store = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 1000 + }) + }) + stores.push(store) + return store + } + const source = open() + await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate('retained source', 'source-storage') + await source.makeAvailable() + const writer = open() + await writer.makeAvailable() + return [source, writer] +} + +afterEach(async () => { + jest.restoreAllMocks() + await runInSeries(stores.splice(0), store => store.destroy()) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) + +test('retains an already-pinned SQLite view across independent writes, idle periods and repeated keyset reads', async () => { + const [source, writer] = await pair() + const { user } = await source.findOrInsertUser(identity) + const when = new Date('2026-01-01T00:00:00.000Z') + await runInSeries( + Array.from({ length: 12 }, (_, index) => index), + async index => { + await source.insertTxLabel({ + txLabelId: 0, + userId: user.userId, + label: `label ${index}`, + isDeleted: index === 5, + created_at: when, + updated_at: when + }) + } + ) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await source.findOrInsertTxLabel(other.userId, 'foreign label') + const expected = await source.findTxLabels({ partial: { userId: user.userId } }) + expect(source.supportsRetainedReadSnapshot()).toBe(true) + const view = await source.openReadSnapshot() + // There has been no consumer read yet. Opening must already have pinned the + // view, rather than choosing an unrelated point in time on the first page. + await writer.updateTxLabel(expected[0].txLabelId, { label: 'changed after open', updated_at: when }) + await writer.updateTxLabel(expected[7].txLabelId, { isDeleted: true, updated_at: when }) + await writer.findOrInsertTxLabel(user.userId, 'inserted after open') + const page = (after: number) => + view.read(trx => + source + .toDb(trx)<{ + txLabelId: number + label: string + isDeleted: boolean | number + }>('tx_labels') + .select('txLabelId', 'label', 'isDeleted') + .where({ userId: user.userId }) + .where('txLabelId', '>', after) + .orderBy('txLabelId') + .limit(4) + ) + const first = await page(0) + await new Promise(resolve => setImmediate(resolve)) + const second = await page(first[3].txLabelId) + const third = await page(second[3].txLabelId) + expect(await page(third[3].txLabelId)).toEqual([]) + expect(await page(0)).toEqual(first) + const result = [...first, ...second, ...third] + expect(result.map(row => [row.txLabelId, row.label, Boolean(row.isDeleted)])).toEqual( + expected.map(row => [row.txLabelId, row.label, row.isDeleted]) + ) + await view.close() + const next = await source.openReadSnapshot() + const current = await next.read(trx => source.findTxLabels({ partial: { userId: user.userId }, trx })) + expect(current).toHaveLength(13) + expect(current.find(row => row.txLabelId === expected[0].txLabelId)?.label).toBe('changed after open') + expect(current.find(row => row.txLabelId === expected[7].txLabelId)?.isDeleted).toBe(true) + await next.close() +}) + +test('cancellation keeps the provider slot occupied until an in-flight database read drains', async () => { + const [source] = await pair() + const { user } = await source.findOrInsertUser(identity) + const controller = new AbortController() + const view = await source.openReadSnapshot({ signal: controller.signal }) + let release!: () => void + const held = new Promise(resolve => { + release = resolve + }) + let started!: () => void + const reading = new Promise(resolve => { + started = resolve + }) + const pending = view.read(async trx => { + const rows = await source.findTxLabels({ partial: { userId: user.userId }, trx }) + started() + await held + return rows + }) + const outcome = pending.catch(error => error) + try { + await reading + controller.abort() + await expect(source.openReadSnapshot()).rejects.toThrow('already has a retained read snapshot') + release() + expect(await outcome).toBeInstanceOf(Error) + await view.closed + const fresh = await source.openReadSnapshot() + await fresh.close() + } finally { + release() + await view.close() + } +}) + +test('a failed read rolls back and permits a later retained view and ordinary write', async () => { + const [source] = await pair() + const { user } = await source.findOrInsertUser(identity) + const view = await source.openReadSnapshot() + const failure = new Error('synthetic snapshot query failure') + await expect( + view.read(async trx => { + await source.findUsers({ partial: { userId: user.userId }, trx }) + throw failure + }) + ).rejects.toBe(failure) + await expect(view.closed).rejects.toBe(failure) + await source.findOrInsertTxLabel(user.userId, 'after rollback') + const fresh = await source.openReadSnapshot() + expect(await fresh.read(trx => source.findTxLabels({ partial: { userId: user.userId }, trx }))).toHaveLength(1) + await fresh.close() +}) + +test('provider destruction closes its idle retained view before destroying the pool', async () => { + const [source] = await pair() + const view = await source.openReadSnapshot() + await source.destroy() + stores.splice(stores.indexOf(source), 1) + await view.closed + expect(view.isOpen).toBe(false) + await expect(view.read(async () => 1)).rejects.toThrow('closed') +}) + +test('IndexedDB retains its scoped snapshot support but explicitly refuses an idle retained transaction', async () => { + const source = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + expect(source.supportsReadSnapshot()).toBe(true) + expect(source.supportsRetainedReadSnapshot()).toBe(false) + await expect(source.openReadSnapshot()).rejects.toThrow('Retained read snapshots are not supported') +}) + +test('reserves the provider slot before acquisition hooks can re-enter opening', async () => { + const [source] = await pair() + const aborted = new AbortController() + aborted.abort() + const run = source.readSnapshot.bind(source) + let nested: Promise | undefined + jest.spyOn(source, 'readSnapshot').mockImplementationOnce(async read => { + nested = source.openReadSnapshot({ signal: aborted.signal }).catch(error => error) + return await run(read) + }) + const view = await source.openReadSnapshot() + expect(await nested).toEqual( + expect.objectContaining({ message: expect.stringContaining('already has a retained read snapshot') }) + ) + await view.close() +}) + +test('provider destruction still destroys the pool if retained-view cleanup reports failure', async () => { + const [source] = await pair() + const cleanupFailure = new Error('synthetic cleanup failure') + const run = source.readSnapshot.bind(source) + jest.spyOn(source, 'readSnapshot').mockImplementationOnce(async read => { + await run(read) + throw cleanupFailure + }) + const view = await source.openReadSnapshot() + await expect(source.destroy()).rejects.toBe(cleanupFailure) + stores.splice(stores.indexOf(source), 1) + await expect(view.closed).rejects.toBe(cleanupFailure) + await expect(source.knex.raw('SELECT 1')).rejects.toThrow('Unable to acquire a connection') +}) + +test('SQL providers with an unsupported database retain the explicit unsupported fallback', async () => { + const [source] = await pair() + jest.spyOn(source, 'supportsReadSnapshot').mockReturnValue(false) + const read = jest.spyOn(source, 'readSnapshot') + expect(source.supportsRetainedReadSnapshot()).toBe(false) + await expect(source.openReadSnapshot()).rejects.toThrow('Retained read snapshots are not supported') + expect(read).not.toHaveBeenCalled() +}) + +test.each([false, true])( + 'destruction fences retained-view admission before asynchronous cleanup (existing view: %s)', + async existing => { + const [source] = await pair() + if (existing) await source.openReadSnapshot() + let release!: () => void + const cleanup = new Promise(resolve => { + release = resolve + }) + let entered!: () => void + const stopping = new Promise(resolve => { + entered = resolve + }) + jest.spyOn(source, 'stopPreparedBeefTasks').mockImplementationOnce(async () => { + entered() + await cleanup + }) + const destruction = source.destroy() + try { + await stopping + // Observe/close an incorrectly admitted view so a failed assertion cannot + // strand the pool. The provider must refuse before any acquisition. + const read = jest.spyOn(source, 'readSnapshot') + const result = await source.openReadSnapshot().catch(error => error) + if (!(result instanceof Error)) await result.close() + expect(result).toEqual(expect.objectContaining({ message: expect.stringContaining('destruction begins') })) + expect(read).not.toHaveBeenCalled() + } finally { + release() + await destruction + stores.splice(stores.indexOf(source), 1) + } + await expect(source.openReadSnapshot()).rejects.toThrow('destruction begins') + } +) diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 4b5576a8a..7206a669d 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 33) + assert.equal(result.summary.propertySuites, 34) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 37) - assert.equal(result.summary.mutationTargets, 33) + assert.equal(result.summary.mutationTargets, 34) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index fa93ead5d..fd4718849 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -74,8 +74,19 @@ JSON fields normalize without changing source history; required values and array entries cannot be silently dropped. Inconsistent owned label/tag mappings reject capture instead of being filtered out of the archive. -This checkpoint is only part of S2/P1. It does not yet implement keysets, immutable -retained snapshots, streaming, bounded push/backup work or staged restore. +The next checkpoint adds a bounded local SQL read-view lifetime. Opening pins a +SQLite/MySQL view before returning, retains it across idle reads, rejects nested +or concurrent reads, and keeps provider capacity occupied until physical cleanup +after close, expiry, cancellation or read failure. Tests cover exact lifetime +boundaries, delayed acquisition/initialization/read/cleanup, independent writers, +profile-filtered reads and randomized operation schedules. The local methods are +absent from the RPC allowlist. Each view occupies one pool connection; query +cancellation/deadlines remain a driver concern. Retention is explicitly +unsupported on IndexedDB. No persisted schema or index transition is introduced. + +These checkpoints are only part of S2/P1. They do not yet implement a bounded +keyset API, authenticated remote views, durable source-view checkpoints, +streaming, bounded push/backup work or staged restore. IndexedDB writers wait during capture and the legacy helpers still materialize the document. A local MySQL 8.4.11 fixture confirms repeatable-read capture under an independent writer, read-only enforcement, unchanged session defaults and From dac9af33c90231e0e29118db6ad6d2e8d2ada81c Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 19:28:52 -0700 Subject: [PATCH 033/127] Observe cancelled snapshot openings until their consumers resume --- packages/wallet/wallet-toolbox/CHANGELOG.md | 2 ++ .../storage/snapshot/RetainedReadSnapshot.test.ts | 13 +++++++++++++ .../src/storage/snapshot/RetainedReadSnapshot.ts | 6 ++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 3907a6540..01d4076c3 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -10,6 +10,8 @@ attention to changes that materially alter behavior or extend functionality. cleanup ownership. Opening pins the transaction before the first consumer read; one view per provider and one read per view prevent unbounded admission. Closing discards late results and awaits physical cleanup, including failed reads. + Internal opening/cleanup rejections remain observed if a view is cancelled + before its opening consumer resumes; callers still receive the original errors. Driver/query deadlines remain separate. Each view occupies a pool connection; IndexedDB and RPC explicitly remain unsupported for retention. This is a paging prerequisite, without changing ordinary backup locks, schemas or wire formats. diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.test.ts index e02eca06c..1b2b0a8ce 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.test.ts @@ -338,3 +338,16 @@ test('acquisition failure clears its expiry timer and removes its signal listene expect(jest.getTimerCount()).toBe(0) expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) }) + +test('closing before consuming the opening promise remains observed and preserves the opening rejection', async () => { + const lifetime = retainReadSnapshot( + async read => await read({ synthetic: true }), + async () => undefined + ) + lifetimes.push(lifetime) + // The owner may drain a cancelled opening before its opening consumer resumes. + // Unlike fixture(), deliberately attach no observer to opened until a later turn. + await lifetime.close() + await new Promise(resolve => setImmediate(resolve)) + await expect(lifetime.opened).rejects.toThrow('closed') +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts index 5c8869495..a56cb4ef6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts @@ -71,8 +71,10 @@ export function retainReadSnapshot( let busy = false let timer: ReturnType | undefined - // Automatic expiry/failure cleanup remains observed even if a caller only - // awaits read(). The original promise still reports cleanup failure to close(). + // A provider can close a still-opening view before its opening consumer + // resumes. Observe automatic rejection of both lifecycle promises while + // preserving their original errors for consumers awaiting opened/closed. + void opened.promise.catch(() => undefined) void closed.promise.catch(() => undefined) const stop = (reason: WERR_INVALID_OPERATION): void => { From 6dcfeada375ce9625e08a926aec5cebb1754977a Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 20:57:19 -0700 Subject: [PATCH 034/127] fix(deps): reconcile patched audit resolutions --- docs/reference/dependency-policy.md | 24 +++++++++++++++--- governance/dependency-release-policy.json | 14 +++++------ governance/repository-health/exceptions.json | 24 ++++++++++++------ infra/message-box-server/package-lock.json | 25 +++++++++---------- infra/message-box-server/package.json | 2 +- infra/uhrp-server-basic/package-lock.json | 12 ++++----- infra/uhrp-server-basic/package.json | 2 +- .../package-lock.json | 12 ++++----- infra/uhrp-server-cloud-bucket/package.json | 2 +- infra/wab/package-lock.json | 18 ++++++------- infra/wab/package.json | 2 +- pnpm-lock.yaml | 23 ++++++++--------- pnpm-workspace.yaml | 2 +- tools/codegen/node/package-lock.json | 6 ++--- tools/codegen/node/package.json | 2 +- 15 files changed, 96 insertions(+), 74 deletions(-) diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index fab3d3ad7..90dd2d414 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -3,8 +3,8 @@ id: dependency-release-policy title: 'Dependency and Release Policy' kind: reference version: '1.3.2' -last_updated: '2026-09-23' -last_verified: '2026-09-23' +last_updated: '2026-09-30' +last_verified: '2026-09-30' review_cadence_days: 30 status: stable tags: [reference, dependencies, security, releases] @@ -136,12 +136,28 @@ must be a dependency or peer, and clean packed consumers must typecheck it. This keeps build-only advisory trees out of consumer installs without shipping unresolvable public declarations. +The September 30 audit refresh also selects compatible transitive releases within +existing ranges: `engine.io` 6.6.10 in the workspace and Message Box server; +`ip-address` 10.7.1 in Message Box, both UHRP servers and WAB; `fast-uri` 3.1.8 in +Message Box; and `undici` 6.28.1 in WAB. These generated lock changes add no +manifest ranges or overrides. All selected versions exceed the seven-day release +age. Upstream releases preserve their module and Node contracts; service suites, +AuthSocket coverage and frozen-install audits qualify the affected consumers. +The relevant upstream releases are [Engine.IO](https://github.com/socketio/socket.io/releases/tag/engine.io%406.6.10), +[ip-address](https://github.com/beaugunderson/ip-address/releases/tag/v10.7.1), +[fast-uri](https://github.com/fastify/fast-uri/releases/tag/v3.1.8) and +[Undici](https://github.com/nodejs/undici/releases/tag/v6.28.1). +Source reconciliation does not release packages or deploy service images. + The root workspace carries six narrow audited dependency overrides: - Jest 30.4.2 still constrains parts of its reporting and coverage graph to minimatch releases with older `brace-expansion` ranges. The follow-up - GHSA-rgw5-rvv9-x895 requires `brace-expansion` 5.0.9, so the workspace - substitutes 5.0.9 until every supported path resolves it natively. + advisories GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p and + GHSA-q2hr-2g5m-vwhr require `brace-expansion` 5.0.12, so the workspace + and existing standalone substitutions select 5.0.12 until every supported + path resolves it natively. The release preserves the existing module exports, + types and Node engine range. - Express/body-parser, Superagent, and Stryker's `typed-rest-client@2.3.1` can retain vulnerable `qs` releases. A version-bounded substitution selects 6.16.0, the first release that also fixes the bracket/comma array-limit bypass diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index c170a3001..07ec3f066 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -153,8 +153,8 @@ "overrideRegistry": [ { "source": "pnpm-workspace.yaml", - "selector": "brace-expansion@<5.0.9", - "value": "5.0.9", + "selector": "brace-expansion@<5.0.12", + "value": "5.0.12", "exceptionId": "brace-expansion-jest-override" }, { @@ -200,7 +200,7 @@ "source": "tools/codegen/node/package.json", "selector": "minimatch@10.2.5", "value": { - "brace-expansion": "5.0.9" + "brace-expansion": "5.0.12" }, "exceptionId": "openapi-typescript-redocly-overrides" }, @@ -213,7 +213,7 @@ { "source": "infra/message-box-server/package.json", "selector": "brace-expansion", - "value": "5.0.9", + "value": "5.0.12", "exceptionId": "brace-expansion-jest-override" }, { @@ -237,7 +237,7 @@ { "source": "infra/uhrp-server-basic/package.json", "selector": "brace-expansion", - "value": "5.0.9", + "value": "5.0.12", "exceptionId": "brace-expansion-jest-override" }, { @@ -249,7 +249,7 @@ { "source": "infra/uhrp-server-cloud-bucket/package.json", "selector": "brace-expansion", - "value": "5.0.9", + "value": "5.0.12", "exceptionId": "brace-expansion-jest-override" }, { @@ -279,7 +279,7 @@ { "source": "infra/wab/package.json", "selector": "brace-expansion", - "value": "5.0.9", + "value": "5.0.12", "exceptionId": "brace-expansion-jest-override" }, { diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index 4d7f28502..e2bda487f 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -73,9 +73,9 @@ { "id": "brace-expansion-jest-override", "category": "override", - "target": "pnpm-workspace.yaml and four standalone Jest service manifests overriding brace-expansion <=5.0.7", + "target": "pnpm-workspace.yaml and four standalone Jest service manifests overriding brace-expansion <5.0.12", "owner": "ts-stack-maintainers", - "reason": "Jest 30.4.2 still constrains reporting and coverage paths to minimatch releases that require older brace-expansion ranges. GHSA-mh99-v99m-4gvg required 5.0.8, and the follow-up GHSA-rgw5-rvv9-x895 requires 5.0.9, so the governed substitution selects the current patched release.", + "reason": "Jest 30.4.2 still constrains reporting and coverage paths to minimatch releases that require older brace-expansion ranges. Review on 2026-09-30 advances the existing substitution from 5.0.9 to 5.0.12 for GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p and GHSA-q2hr-2g5m-vwhr. The patched release preserves the CommonJS/ESM exports, types and Node engine range; frozen installs, service tests and coverage/mutation qualification verify the existing compatibility contract.", "evidence": [ "pnpm-workspace.yaml#overrides", "pnpm-lock.yaml#overrides", @@ -86,11 +86,15 @@ "infra/wab/package.json#overrides", "https://github.com/advisories/GHSA-mh99-v99m-4gvg", "https://github.com/advisories/GHSA-rgw5-rvv9-x895", - "https://github.com/bsv-blockchain/ts-stack/issues/324" + "https://github.com/bsv-blockchain/ts-stack/issues/324", + "https://github.com/advisories/GHSA-qhr7-859c-m2p7", + "https://github.com/advisories/GHSA-6j4f-fj2g-mc7p", + "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr", + "https://github.com/juliangruber/brace-expansion/releases/tag/v5.0.12" ], "created": "2026-07-25", - "reviewBy": "2026-09-25", - "removeWhen": "Remove after the supported Jest dependency graph natively resolves brace-expansion 5.0.9 or newer across every coverage and reporting path." + "reviewBy": "2026-10-30", + "removeWhen": "Remove after the supported Jest dependency graph natively resolves brace-expansion 5.0.12 or newer across every coverage and reporting path." }, { "id": "standalone-gaxios-advisory-override", @@ -216,7 +220,7 @@ "category": "override", "target": "tools/codegen/node/package.json overrides for @redocly/openapi-core@1.34.17", "owner": "ts-stack-maintainers", - "reason": "The latest openapi-typescript release still depends on Redocly 1.x ranges that resolve vulnerable js-yaml and minimatch paths, so the isolated codegen lock substitutes compatible patched releases and verifies the generated output byte-for-byte.", + "reason": "The latest openapi-typescript release still depends on Redocly 1.x ranges that resolve vulnerable js-yaml and minimatch paths, so the isolated codegen lock substitutes compatible patched releases and verifies the generated output byte-for-byte. Review on 2026-09-30 advances only the existing nested brace-expansion substitution to 5.0.12 for the three follow-up advisories, preserving exports, types and the Node engine range.", "evidence": [ "tools/codegen/node/package.json#overrides", "tools/codegen/node/package-lock.json", @@ -225,10 +229,14 @@ "https://github.com/advisories/GHSA-mh99-v99m-4gvg", "https://github.com/advisories/GHSA-rgw5-rvv9-x895", "https://github.com/openapi-ts/openapi-typescript/blob/openapi-typescript%407.13.0/packages/openapi-typescript/package.json", - "https://github.com/advisories/GHSA-2883-xcg3-v3hh" + "https://github.com/advisories/GHSA-2883-xcg3-v3hh", + "https://github.com/advisories/GHSA-qhr7-859c-m2p7", + "https://github.com/advisories/GHSA-6j4f-fj2g-mc7p", + "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr", + "https://github.com/juliangruber/brace-expansion/releases/tag/v5.0.12" ], "created": "2026-07-25", - "reviewBy": "2026-09-25", + "reviewBy": "2026-10-30", "removeWhen": "Remove when a supported openapi-typescript release natively resolves non-vulnerable js-yaml, minimatch, and brace-expansion versions, then regenerate and compare every committed output." }, { diff --git a/infra/message-box-server/package-lock.json b/infra/message-box-server/package-lock.json index c263ef630..2255908eb 100644 --- a/infra/message-box-server/package-lock.json +++ b/infra/message-box-server/package-lock.json @@ -5603,9 +5603,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -6273,16 +6273,15 @@ } }, "node_modules/engine.io": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", - "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "version": "6.6.10", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.10.tgz", + "integrity": "sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", - "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", @@ -6696,9 +6695,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -7784,9 +7783,9 @@ } }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.1.tgz", + "integrity": "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/infra/message-box-server/package.json b/infra/message-box-server/package.json index 8d38738e4..2038cdd2a 100644 --- a/infra/message-box-server/package.json +++ b/infra/message-box-server/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.1.47", "overrides": { - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", "gaxios": "7.3.0", "uuid": "11.1.1" }, diff --git a/infra/uhrp-server-basic/package-lock.json b/infra/uhrp-server-basic/package-lock.json index fdad9b5b8..e732ba145 100644 --- a/infra/uhrp-server-basic/package-lock.json +++ b/infra/uhrp-server-basic/package-lock.json @@ -4471,9 +4471,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5817,9 +5817,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.1.tgz", + "integrity": "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/infra/uhrp-server-basic/package.json b/infra/uhrp-server-basic/package.json index b7dec6361..36607eab3 100644 --- a/infra/uhrp-server-basic/package.json +++ b/infra/uhrp-server-basic/package.json @@ -2,7 +2,7 @@ "name": "@bsv/uhrp-lite", "version": "0.1.45", "overrides": { - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", "gaxios": "7.3.0" }, "engines": { diff --git a/infra/uhrp-server-cloud-bucket/package-lock.json b/infra/uhrp-server-cloud-bucket/package-lock.json index 58d45366a..2db21f16e 100644 --- a/infra/uhrp-server-cloud-bucket/package-lock.json +++ b/infra/uhrp-server-cloud-bucket/package-lock.json @@ -4983,9 +4983,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6709,9 +6709,9 @@ } }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.1.tgz", + "integrity": "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/infra/uhrp-server-cloud-bucket/package.json b/infra/uhrp-server-cloud-bucket/package.json index f488708b0..15e9d4ce2 100644 --- a/infra/uhrp-server-cloud-bucket/package.json +++ b/infra/uhrp-server-cloud-bucket/package.json @@ -2,7 +2,7 @@ "name": "@bsv/uhrp-storage-server", "version": "0.2.49", "overrides": { - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", "gaxios": "7.3.0", "uuid": "11.1.1" }, diff --git a/infra/wab/package-lock.json b/infra/wab/package-lock.json index 533c2b484..488459fa1 100644 --- a/infra/wab/package-lock.json +++ b/infra/wab/package-lock.json @@ -4661,9 +4661,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6292,9 +6292,9 @@ } }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.1.tgz", + "integrity": "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA==", "license": "MIT", "engines": { "node": ">= 12" @@ -9873,9 +9873,9 @@ } }, "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "license": "MIT", "optional": true, "engines": { diff --git a/infra/wab/package.json b/infra/wab/package.json index d0209039d..69155f0fe 100644 --- a/infra/wab/package.json +++ b/infra/wab/package.json @@ -4,7 +4,7 @@ "private": true, "version": "1.8.7", "overrides": { - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", "gaxios": "7.3.0", "js-yaml": "3.15.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ba515ec3..ea972ffd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion@<5.0.9: 5.0.9 + brace-expansion@<5.0.12: 5.0.12 nanoid@<3.3.18: 3.3.18 toml@<4.2.0: 4.2.0 qs@<6.16.0: 6.16.0 @@ -4913,8 +4913,8 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} braces@3.0.3: @@ -5535,8 +5535,8 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} - engine.io@6.6.9: - resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} + engine.io@6.6.10: + resolution: {integrity: sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==} engines: {node: '>=10.2.0'} enhanced-resolve@5.24.4: @@ -11962,7 +11962,7 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -12527,13 +12527,12 @@ snapshots: engine.io-parser@5.2.3: {} - engine.io@6.6.9: + engine.io@6.6.10: dependencies: '@types/cors': 2.8.19 '@types/node': 26.1.2 '@types/ws': 8.18.1 accepts: 1.3.8 - base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 debug: 4.4.3 @@ -14708,15 +14707,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@9.0.9: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimist@1.2.8: {} @@ -15744,7 +15743,7 @@ snapshots: base64id: 2.0.0 cors: 2.8.6 debug: 4.4.3 - engine.io: 6.6.9 + engine.io: 6.6.10 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.7 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c8b3f1d50..f05bb135f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,7 +33,7 @@ minimumReleaseAgeExclude: # brace-expansion 1.x/2.x. Keep this narrow, tested substitution on the latest # security-patched release until Jest adopts it directly. overrides: - brace-expansion@<5.0.9: 5.0.9 + brace-expansion@<5.0.12: 5.0.12 # Vite's PostCSS graph permits nanoid 3 releases older than the security # fix. Select the first patched 3.x release without changing its public API. nanoid@<3.3.18: 3.3.18 diff --git a/tools/codegen/node/package-lock.json b/tools/codegen/node/package-lock.json index 6076a0bf9..f19227160 100644 --- a/tools/codegen/node/package-lock.json +++ b/tools/codegen/node/package-lock.json @@ -113,9 +113,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" diff --git a/tools/codegen/node/package.json b/tools/codegen/node/package.json index 67312a942..377b81716 100644 --- a/tools/codegen/node/package.json +++ b/tools/codegen/node/package.json @@ -14,7 +14,7 @@ "minimatch": "10.2.5" }, "minimatch@10.2.5": { - "brace-expansion": "5.0.9" + "brace-expansion": "5.0.12" } }, "license": "SEE LICENSE IN LICENSE.txt" From bd1edc7afe31c03bcb81786b758fa37394ec3bb3 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 20:59:01 -0700 Subject: [PATCH 035/127] feat(wallet): add bounded profile snapshot keyset pages --- docs/guides/wallet-sync-reliability.md | 62 +- docs/reference/package-api-migrations.md | 84 +-- governance/mutation-testing/policy.json | 2 +- governance/mutation-testing/targets.mjs | 1 + governance/package-release-notes.json | 12 +- governance/test-quality/policy.json | 7 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 8 + packages/wallet/wallet-toolbox/README.md | 13 + .../wallet/wallet-toolbox/client/README.md | 6 + .../wallet/wallet-toolbox/mobile/README.md | 6 + .../wallet-toolbox/src/storage/StorageKnex.ts | 14 + .../src/storage/StorageProvider.ts | 10 + .../wallet-toolbox/src/storage/index.all.ts | 11 + .../src/storage/index.client.ts | 11 + .../src/storage/index.mobile.ts | 11 + .../remoting/__test/StorageServerRpc.test.ts | 9 +- .../KnexWalletReadSnapshot.mysql.test.ts | 171 +++++ .../snapshot/KnexWalletReadSnapshot.test.ts | 587 ++++++++++++++++++ .../snapshot/KnexWalletReadSnapshot.ts | 313 ++++++++++ .../RetainedReadSnapshot.property.test.ts | 83 +++ .../storage/snapshot/WalletReadSnapshot.ts | 78 +++ specs/wallet/sync-portability-program.md | 13 +- 22 files changed, 1456 insertions(+), 56 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/WalletReadSnapshot.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 471c59d9b..8c64c65cd 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -153,12 +153,72 @@ read-only repeatable-read transaction without changing pooled session defaults. SQLite relies on the trusted callback's read-only contract. `StorageIdb` and unsupported providers explicitly refuse retained views; their existing scoped snapshot behavior is unchanged. Process exit loses the view. +The lifetime limits retention time, not the bytes accumulated in database WAL or +undo history while other writers continue; database storage limits remain separate. -This primitive does not yet provide a bounded paging API, durable cursor, +The primitive itself does not provide a durable cursor, remote handle, concurrent IndexedDB snapshot, or streaming archive. Ordinary push/backup loops still require the scheduling and checkpoint work below. No persisted schema, legacy index order or wire encoding changes in this checkpoint. +### Profile-bound local SQL pages + +`StorageKnex.openWalletReadSnapshot(identityKey, { lifetimeMs, signal })` adds a +version-one typed paging interface over the same retained lifetime. Inspect +`supportsWalletReadSnapshot()` first; the base provider and IndexedDB explicitly +refuse it. The API and its capability are excluded from RPC. These are trusted +local database operations, not remote authentication or complete archive validation. + +The returned source settings, user and all thirteen standard tables share one +view. No primary activation or managed-change policy is applied. Proofs include +those referenced only through the user's transaction requests. Deleted labels, +tags, mappings, baskets and certificates remain present. Inconsistent profile +ownership across mappings or certificate fields rejects the view rather than +silently omitting a relationship. Binary columns are `Uint8Array`, never expanded +number arrays; stored scripts remain stored values, without reconstruction. + +```ts +const view = await storage.openWalletReadSnapshot(identityKey, { signal }) +try { + let cursor + for (;;) { + const page = await view.readPage('transactions', cursor, { + maxRows: 128, + maxBytes: 262144 + }) + await consumePackedRows(page.rows) + if (page.done) break + cursor = page.cursor + } +} finally { + await view.close() +} +``` + +Each cursor binds the view and table. Retrying the same position and limits +repeats a page. Process loss, cancellation, close or expiry invalidates that +position; a new view cannot accept it. It is not a durable destination checkpoint. +One read is allowed at a time and each provider retains only one SQL view. +The existing pool, query-deadline and lifetime limits above still apply. + +Pages default to 128 rows and a 262,144-byte payload charge; limits are integer +values from 1 to 1,000 rows and 1 to 16,777,216 bytes. SQL first returns bounded +keys and cell lengths. The charge is twice each cell's stored byte length plus +64 bytes per cell. Only the prefix fitting both limits is fetched. A first row +over budget explicitly rejects, without loading or skipping that row. This +charge bounds stored page payload, not encoded wire size or measured process +RSS; callers must release consumed pages. Large-value streaming remains required +for records exceeding the maximum budget, and header/schema metadata is separate. + +Traversal uses existing unique keys, including label/tag-first mapping keys and +field-name-first certificate keys, with each database's collation. This order is +not the canonical BRC-38 array order. No OFFSET, full-table count, new index or +persistence migration is introduced. Existing legacy sync checkpoints and query +plans are unchanged. SQLite query-plan tests verify range seeks for numeric and +composite keys. Identity/update-key indexing, commit-order incremental high-water +positions, remote handles, IDB retention, streaming and resumable push/backup +remain part of the active program; this page API does not enable them by itself. + ## Next-stage design checkpoint (not implemented) A future source-snapshot capability should negotiate a versioned contract diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index e342a8c97..b5fb7099d 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC and do not yet change ordinary backup scheduling. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC and do not yet change ordinary backup scheduling. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -537,8 +537,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -549,8 +549,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 7df506c4f..d22d339ce 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -62,7 +62,7 @@ "manifest": "packages/wallet/wallet-toolbox/package.json", "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts", "risk": "critical", - "boundary": "Wallet retained database-view admission, expiry, cancellation and physical cleanup ownership", + "boundary": "Wallet retained database-view admission, expiry, cancellation and physical cleanup ownership, profile-bound keyset cursors and bounded packed payload admission", "minimumScore": 90, "maximumNoCoverage": 0, "maximumInvalid": 0 diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index a1bd1fdd9..63e71caef 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -237,6 +237,7 @@ export function buildMutationTargets(repositoryRoot) { 'packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts', mutate: [ 'src/storage/snapshot/RetainedReadSnapshot.ts', + 'src/storage/snapshot/KnexWalletReadSnapshot.ts', sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 0b89379d6..0e8ac83b7 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC and do not yet change ordinary backup scheduling.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC and do not yet change ordinary backup scheduling. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged." + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows.", + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged." + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows.", + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program." }, { "name": "create-bsv-app", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 290de3e21..8268894e5 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -99,13 +99,14 @@ "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts", "manifest": "packages/wallet/wallet-toolbox/package.json", "risk": "critical", - "boundary": "Wallet retained database-view admission, expiry, cancellation and physical cleanup ownership", - "target": "Randomized read, cancellation, close and expiry schedules with an independent admission and cleanup model", + "boundary": "Wallet retained database-view admission, expiry, cancellation and physical cleanup ownership, profile-bound keyset cursors and bounded packed payload admission", + "target": "Randomized read, cancellation, close and expiry schedules with an independent admission and cleanup model; randomized source profiles, payload/page limits and independent writes against pinned SQL views", "invariants": [ "At most one database read is admitted and concurrent or nested reads do not queue.", "Closing, cancellation and expiry reject later admission and discard late successful results.", "Capacity remains occupied until physical reads and transaction cleanup settle.", - "Every completed lifecycle removes its expiry timer and rejects further reads." + "Every completed lifecycle removes its expiry timer and rejects further reads.", + "Pinned keyset traversal returns every original owned row and tombstone exactly once under independent writes, within both page limits; cursor retries repeat the same page." ] }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 01d4076c3..fdfb9803b 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,14 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add local profile-bound SQL keyset pages over retained views. All thirteen + standard tables preserve tombstones and original source records; binary values + stay packed. SQL preflights bounded keys and payload sizes before fetching a + page. Oversized individual rows explicitly refuse pending large-value streaming. + Cursors belong only to their live view and table; expiry/process loss requires + restart. Existing indexes, schemas, OFFSET checkpoints and RPC remain unchanged. + This is a prerequisite for the active streaming/resumable-backup program. + - Add local retained SQLite/MySQL views with explicit lifetime, cancellation and cleanup ownership. Opening pins the transaction before the first consumer read; one view per provider and one read per view prevent unbounded admission. Closing diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 69921244f..e5f8271b0 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -1221,3 +1221,16 @@ for the full stack-wide policy. This package is released under the [Open BSV License Version 6](./LICENSE.txt). The accompanying [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) and [LICENSES/](./LICENSES/) preserve the package's earlier Open BSV grant. + +### Local packed snapshot pages (2.15 candidate) + +SQL providers expose `supportsWalletReadSnapshot()` and +`openWalletReadSnapshot(identityKey, options)`. Settings, the selected user and +all thirteen standard tables share one retained read view. `readPage(table, +cursor, { maxRows, maxBytes })` uses stable storage keys and checks payload size +before loading rows; binary columns remain `Uint8Array`. Always close the view. +Cursors expire with the view and cannot resume after process loss. Oversized rows +explicitly refuse pending large-value streaming; no schema/index or legacy sync +change is introduced. IndexedDB and RPC do not expose this capability. See the +[page contract and limits](../../../docs/guides/wallet-sync-reliability.md#profile-bound-local-sql-pages). +The full #544 program remains incomplete. diff --git a/packages/wallet/wallet-toolbox/client/README.md b/packages/wallet/wallet-toolbox/client/README.md index 9766ee175..9af79c612 100644 --- a/packages/wallet/wallet-toolbox/client/README.md +++ b/packages/wallet/wallet-toolbox/client/README.md @@ -227,3 +227,9 @@ reference, reclaim, commission, and relation lookups avoid repeated full-wallet scans during restores. Legacy duplicate transaction IDs remain intact. Clients that request an older IndexedDB schema version cannot reopen this database; retain a compatible client when using the local backup. + +The 2.15 candidate also exports version-one `WalletReadSnapshot`, cursor/page and +packed-row types. The base provider explicitly refuses unsupported local pages; +only `StorageKnex` currently implements them. Type availability does not imply an +IndexedDB, native mobile or remote snapshot implementation. Existing sync and +archive APIs retain their behavior. See the [SQL paging contract](../../../../docs/guides/wallet-sync-reliability.md#profile-bound-local-sql-pages). diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 2a4dc89d6..0b958799b 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -263,3 +263,9 @@ This package is released under the [Open BSV License Version 6](./LICENSE.txt). The accompanying [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) and [LICENSES/](./LICENSES/) preserve earlier Open BSV grants compiled into the mobile build. + +The 2.15 candidate also exports version-one `WalletReadSnapshot`, cursor/page and +packed-row types. The base provider explicitly refuses unsupported local pages; +only `StorageKnex` currently implements them. Type availability does not imply an +IndexedDB, native mobile or remote snapshot implementation. Existing sync and +archive APIs retain their behavior. See the [SQL paging contract](../../../../docs/guides/wallet-sync-reliability.md#profile-bound-local-sql-pages). diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index a709b38ed..84d4928f4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,3 +1,5 @@ +import type { WalletReadSnapshot, WalletReadSnapshotOptions } from './snapshot/WalletReadSnapshot' +import { openKnexWalletReadSnapshot } from './snapshot/KnexWalletReadSnapshot' import { retainReadSnapshot, type RetainedReadSnapshot, @@ -239,6 +241,18 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide await this.retainedReadSnapshot?.close() } + override supportsWalletReadSnapshot(): boolean { + return this.supportsRetainedReadSnapshot() + } + + override async openWalletReadSnapshot( + identityKey: string, + options: WalletReadSnapshotOptions = {} + ): Promise { + if (!this.supportsWalletReadSnapshot()) return await super.openWalletReadSnapshot(identityKey, options) + return await openKnexWalletReadSnapshot(this, identityKey, options) + } + private async readMySQLSnapshot(read: (trx: TrxToken) => Promise): Promise { const client = this.knex.client const connection = await client.acquireConnection() diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index b090075de..4dbeeb08f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -1,3 +1,4 @@ +import type { WalletReadSnapshot, WalletReadSnapshotOptions } from './snapshot/WalletReadSnapshot' import type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' import { runInSeries } from '../utility/runInSeries' import { findProofRecords, mapProofWork } from './methods/proofWork' @@ -544,6 +545,15 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal return Promise.reject(new WERR_NOT_IMPLEMENTED('Retained read snapshots are not supported by this provider')) } + /** Local, profile-bound packed keyset pages; never implied by ordinary sync support. */ + supportsWalletReadSnapshot(): boolean { + return false + } + + openWalletReadSnapshot(_identityKey: string, _options: WalletReadSnapshotOptions = {}): Promise { + return Promise.reject(new WERR_NOT_IMPLEMENTED('Wallet read snapshot pages are not supported by this provider')) + } + protected supportsActionBatchPersistence(): boolean { return false } diff --git a/packages/wallet/wallet-toolbox/src/storage/index.all.ts b/packages/wallet/wallet-toolbox/src/storage/index.all.ts index ceb147324..9e09b93f0 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.all.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.all.ts @@ -22,3 +22,14 @@ export * as sync from './sync' export * from './portable' export type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' + +export type { + PackedSnapshotRow, + WalletReadSnapshot, + WalletReadSnapshotOptions, + WalletSnapshotCursor, + WalletSnapshotPage, + WalletSnapshotPageLimits, + WalletSnapshotTable, + WalletSnapshotTables +} from './snapshot/WalletReadSnapshot' diff --git a/packages/wallet/wallet-toolbox/src/storage/index.client.ts b/packages/wallet/wallet-toolbox/src/storage/index.client.ts index 822df52b6..fcaa79c41 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.client.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.client.ts @@ -13,3 +13,14 @@ export * from './methods/managedChange' export * from './methods/managedChangePolicy' export type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' + +export type { + PackedSnapshotRow, + WalletReadSnapshot, + WalletReadSnapshotOptions, + WalletSnapshotCursor, + WalletSnapshotPage, + WalletSnapshotPageLimits, + WalletSnapshotTable, + WalletSnapshotTables +} from './snapshot/WalletReadSnapshot' diff --git a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts index 60cc64fe3..e463ccaa4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts @@ -11,3 +11,14 @@ export * from './methods/ListOutputsSpecOp' export * from './methods/managedChangePolicy' export type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' + +export type { + PackedSnapshotRow, + WalletReadSnapshot, + WalletReadSnapshotOptions, + WalletSnapshotCursor, + WalletSnapshotPage, + WalletSnapshotPageLimits, + WalletSnapshotTable, + WalletSnapshotTables +} from './snapshot/WalletReadSnapshot' diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts index 914c793b7..2cdbf1090 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts @@ -409,7 +409,14 @@ describe('StorageServer JSON-RPC boundary', () => { }) }) - test.each(['readSnapshot', 'supportsReadSnapshot', 'openReadSnapshot', 'supportsRetainedReadSnapshot'])( + test.each([ + 'readSnapshot', + 'supportsReadSnapshot', + 'openReadSnapshot', + 'supportsRetainedReadSnapshot', + 'openWalletReadSnapshot', + 'supportsWalletReadSnapshot' + ])( 'keeps local snapshot method %s outside the authenticated RPC surface', async method => { const handler = jest.fn(async () => undefined) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts new file mode 100644 index 000000000..697b8772d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts @@ -0,0 +1,171 @@ +import { knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' + +const identity = '02' + '11'.repeat(32) +const when = new Date('2026-01-01T00:00:00.000Z') + +/** Actual installed Knex MySQL compiler/transaction protocol with a bounded driver response oracle. */ +function fixture(fieldCount = 6, bytes = 524, certificate = false, deleted: boolean | number = 0) { + const db = knex({ client: 'mysql2' }) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: db }) + const queries: Array<{ sql: string; bindings: unknown[] }> = [] + const fields = certificate + ? ['fieldName', 'certificateId', 'userId', 'created_at', 'updated_at', 'fieldValue', 'masterKey'] + : ['txLabelId', 'userId', 'created_at', 'updated_at', 'label', 'isDeleted'] + const table = certificate ? 'certificate_fields' : 'tx_labels' + const fieldName = '😀'.repeat(100) + const connection = { + destroy() {}, + query( + query: { sql: string }, + bindings: unknown[], + callback: (error: Error | null, rows?: unknown[], fields?: unknown[]) => void + ) { + queries.push({ sql: query.sql, bindings }) + if (query.sql.startsWith('SELECT COLUMN_NAME')) { + expect(query.sql).toBe( + 'SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION LIMIT 65' + ) + expect(bindings).toEqual([table]) + callback( + null, + Array.from({ length: fieldCount }, (_, i) => ({ name: fields[i] ?? `extra${i}` })), + [] + ) + } else if (query.sql.includes('__snapshotBytes')) { + if (certificate) { + expect(query.sql).toContain('case when octet_length(`fieldName`) <= 400 then `fieldName` end as `fieldName`') + expect(query.sql).toContain('`certificate_fields`.`userId` = ?') + callback(null, [{ fieldName, certificateId: 1, __snapshotBytes: bytes, __snapshotOwned: 1 }], []) + } else { + expect(query.sql).toContain('octet_length(`label`)') + expect(query.sql).toContain('`tx_labels`.`userId` = ?') + expect(bindings).toEqual([1, 128]) + callback(null, [{ txLabelId: 1, __snapshotBytes: bytes, __snapshotOwned: 1 }], []) + } + } else if (query.sql.includes('`certificate_fields`.*')) { + callback( + null, + [ + { + fieldName, + certificateId: 1, + userId: 1, + created_at: when, + updated_at: when, + fieldValue: 'value', + masterKey: 'key' + } + ], + [] + ) + } else if (query.sql.includes('`tx_labels`.*')) { + callback( + null, + [{ txLabelId: 1, userId: 1, created_at: when, updated_at: when, label: 'retained', isDeleted: deleted }], + [] + ) + } else if (/^(SET TRANSACTION|BEGIN|COMMIT|ROLLBACK)/.test(query.sql)) callback(null, [], []) + else callback(new Error('Unexpected synthetic driver query')) + } + } + jest.spyOn(db.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(db.client, 'releaseConnection').mockResolvedValue(undefined) + const settings = { + created_at: when, + updated_at: when, + storageIdentityKey: 'synthetic', + storageName: 'synthetic MySQL', + chain: 'test' as const, + dbtype: 'MySQL' as const, + maxOutputScript: 1024 + } + source._settings = settings + jest.spyOn(source, 'makeAvailable').mockResolvedValue(settings) + jest.spyOn(source, 'readSettings').mockResolvedValue(settings) + jest.spyOn(source, 'findUserByIdentityKey').mockResolvedValue({ + created_at: when, + updated_at: when, + userId: 1, + identityKey: identity, + activeStorage: 'synthetic' + }) + return { source, db, queries } +} + +afterEach(() => jest.restoreAllMocks()) + +test('MySQL preflights complete Unicode keys and preserves a boolean-returning pool typecast', async () => { + const { source } = fixture(7, 1352, true) + try { + const view = await source.openWalletReadSnapshot(identity) + const page = await view.readPage('certificateFields') + expect(page.rows[0].fieldName).toBe('😀'.repeat(100)) + expect(page.cursor?.after).toEqual(['😀'.repeat(100), 1]) + await view.close() + } finally { + await source.destroy() + } + const second = fixture(6, 524, false, false) + try { + const view = await second.source.openWalletReadSnapshot(identity) + expect((await view.readPage('txLabels')).rows[0].isDeleted).toBe(false) + await view.close() + } finally { + await second.source.destroy() + } +}) + +test.each([6, 64])( + 'MySQL metadata resolves DATABASE() without a configured connection database and caches %s columns', + async count => { + const { source, db, queries } = fixture(count) + try { + const view = await source.openWalletReadSnapshot(identity) + const first = await view.readPage('txLabels') + expect(first.rows[0]).toMatchObject({ + txLabelId: 1, + userId: 1, + label: 'retained', + isDeleted: false, + created_at: when + }) + expect(first.payloadBytes).toBe(524) + expect(first.done).toBe(true) + expect(await view.readPage('txLabels')).toEqual(first) + expect(queries.filter(query => query.sql.startsWith('SELECT COLUMN_NAME'))).toHaveLength(1) + await view.close() + } finally { + await source.destroy() + await db.destroy() + } + } +) + +test.each([0, 65])('rejects an unsupported schema with %s columns before row payload queries', async count => { + const { source, queries } = fixture(count) + try { + const view = await source.openWalletReadSnapshot(identity) + await expect(view.readPage('txLabels')).rejects.toThrow('Unsupported snapshot schema') + await expect(view.closed).rejects.toThrow('Unsupported snapshot schema') + expect(queries.some(query => query.sql.includes('__snapshotBytes'))).toBe(false) + } finally { + await source.destroy() + } +}) + +test.each([-1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + 'invalid driver row charge %s cannot admit payload allocation', + async bytes => { + const { source, queries } = fixture(6, bytes) + try { + const view = await source.openWalletReadSnapshot(identity) + await expect(view.readPage('txLabels')).rejects.toThrow('Invalid snapshot row size') + await expect(view.closed).rejects.toThrow('Invalid snapshot row size') + expect(queries.some(query => query.sql.includes('`tx_labels`.*'))).toBe(false) + } finally { + await source.destroy() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts new file mode 100644 index 000000000..4bb2d5e3c --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts @@ -0,0 +1,587 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex, type Knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageIdb } from '../StorageIdb' +import { StorageProvider } from '../StorageProvider' +import type { + WalletSnapshotCursor, + WalletSnapshotTable, + WalletReadSnapshot, + PackedSnapshotRow, + WalletSnapshotTables +} from './WalletReadSnapshot' +import { runInSeries } from '../../utility/runInSeries' + +const identity = '02' + '11'.repeat(32) +const foreignIdentity = '03' + '22'.repeat(32) +const date = '2026-01-01T00:00:00.000Z' +const timestamp = { created_at: date, updated_at: date } +const stores: StorageKnex[] = [] +const directories: string[] = [] + +async function fixture(): Promise<{ source: StorageKnex; writer: StorageKnex; userId: number; otherId: number }> { + const directory = await mkdtemp(join(tmpdir(), 'wallet-keyset-')) + directories.push(directory) + const open = () => { + const store = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 1000 + }) + }) + stores.push(store) + return store + } + const source = open() + await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate('keyset source', 'source-storage') + await source.makeAvailable() + const { user } = await source.findOrInsertUser(identity) + const { user: other } = await source.findOrInsertUser(foreignIdentity) + const writer = open() + await writer.makeAvailable() + return { source, writer, userId: user.userId, otherId: other.userId } +} + +async function labels(source: StorageKnex, userId: number, count: number): Promise { + await runInSeries( + Array.from({ length: count }, (_, index) => index), + async index => { + await source.knex('tx_labels').insert({ + ...timestamp, + userId, + label: `label-${index}`, + isDeleted: index % 3 === 0 + }) + } + ) +} + +async function all( + view: WalletReadSnapshot, + table: T +): Promise>> { + const rows: Array> = [] + let cursor: WalletSnapshotCursor | undefined + for (;;) { + const page = await view.readPage(table, cursor, { maxRows: 1 }) + rows.push(...page.rows) + if (page.done) return rows + expect(page.cursor).toBeDefined() + cursor = page.cursor + } +} + +afterEach(async () => { + jest.restoreAllMocks() + await runInSeries(stores.splice(0), source => source.destroy()) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) + +test('keyset pages pin profile, source metadata, equal timestamps and tombstones across independent writes', async () => { + const { source, writer, userId, otherId } = await fixture() + await labels(source, userId, 9) + await labels(source, otherId, 9) + const expected = await source.findTxLabels({ partial: { userId } }) + await writer.knex('settings').update({ storageName: 'uncached name' }) + expect(source.getSettings().storageName).toBe('keyset source') + const view = await source.openWalletReadSnapshot(identity) + expect(view.version).toBe(1) + expect(view.snapshotId).toMatch(/^[a-f0-9]{64}$/) + expect(view.sourceStorage.storageName).toBe('uncached name') + expect(view.user.userId).toBe(userId) + expect(view.isOpen).toBe(true) + await writer.knex('tx_labels').where({ txLabelId: expected[0].txLabelId }).update({ label: 'new value' }) + await writer.knex('tx_labels').where({ txLabelId: expected[4].txLabelId }).del() + await writer.findOrInsertTxLabel(userId, 'new row') + await writer.knex('users').where({ userId }).update({ activeStorage: 'new primary' }) + const first = await view.readPage('txLabels', undefined, { maxRows: 3 }) + expect(first.rows).toEqual(expected.slice(0, 3)) + expect(first.done).toBe(false) + expect(await view.readPage('txLabels', undefined, { maxRows: 3 })).toEqual(first) + // Returned metadata is descriptive; mutating it cannot change the bound query. + view.user.userId = otherId + const next = await view.readPage('txLabels', first.cursor, { maxRows: 3 }) + expect(next.rows).toEqual(expected.slice(3, 6)) + expect(await all(view, 'txLabels')).toEqual(expected) + await view.close() + await view.closed + expect(view.isOpen).toBe(false) + await expect(view.readPage('txLabels')).rejects.toThrow('closed') + const fresh = await source.openWalletReadSnapshot(identity) + expect(fresh.user.activeStorage).toBe('new primary') + await expect(fresh.readPage('txLabels', first.cursor)).rejects.toThrow('cursor') + expect(await all(fresh, 'txLabels')).not.toEqual(expected) + await fresh.close() +}) + +test('byte preflight stops before an oversized row without loading its payload or skipping it', async () => { + const { source, userId } = await fixture() + await labels(source, userId, 2) + await source.knex('tx_labels').insert({ ...timestamp, userId, label: 'x'.repeat(500000), isDeleted: false }) + const view = await source.openWalletReadSnapshot(identity) + const first = await view.readPage('txLabels', undefined, { maxRows: 1 }) + const second = await view.readPage('txLabels', first.cursor, { maxRows: 1, maxBytes: first.payloadBytes }) + expect(second.rows).toHaveLength(1) + const bounded = await view.readPage('txLabels', undefined, { maxRows: 3, maxBytes: first.payloadBytes * 2 }) + expect(bounded.rows).toHaveLength(2) + expect(bounded.payloadBytes).toBe(first.payloadBytes * 2) + expect(bounded.done).toBe(false) + const queries: string[] = [] + source.knex.on('query', q => queries.push(q.sql)) + await expect(view.readPage('txLabels', second.cursor)).rejects.toThrow('row exceeds maxBytes') + await expect(view.closed).rejects.toThrow('row exceeds maxBytes') + expect(queries.filter(sql => /select/i.test(sql))).toHaveLength(1) + expect(queries[0]).toContain('length(cast(') + expect(queries[0]).not.toContain('`tx_labels`.*') + expect(queries[0]).not.toMatch(/offset|count\(/i) + const next = await source.openWalletReadSnapshot(identity) + expect((await next.readPage('txLabels', undefined, { maxRows: 3, maxBytes: 2000000 })).rows).toHaveLength(3) + await next.close() +}) + +test.each([ + { maxRows: 0 }, + { maxRows: -1 }, + { maxRows: 1.5 }, + { maxRows: 1001 }, + { maxRows: NaN }, + { maxBytes: 0 }, + { maxBytes: -1 }, + { maxBytes: 1.5 }, + { maxBytes: 16777217 }, + { maxBytes: Infinity } +])('rejects invalid allocation limits before executing SQL: %j', async limits => { + const { source } = await fixture() + const view = await source.openWalletReadSnapshot(identity) + const read = jest.spyOn(source, 'toDb') + await expect(view.readPage('txLabels', undefined, limits)).rejects.toThrow('integer') + expect(read).not.toHaveBeenCalled() + expect(view.isOpen).toBe(true) + await view.close() +}) + +test('rejects table and cursor confusion before querying and detaches caller cursors', async () => { + const { source, userId } = await fixture() + await labels(source, userId, 3) + const view = await source.openWalletReadSnapshot(identity) + const { cursor } = await view.readPage('txLabels', undefined, { maxRows: 1 }) + const read = jest.spyOn(source, 'toDb') + for (const table of ['settings', 'users', '__proto__', 'constructor', 'tx_labels']) { + await expect(view.readPage(table as WalletSnapshotTable)).rejects.toThrow('table') + } + for (const invalid of [ + null, + { ...cursor, version: 2 }, + { ...cursor, snapshotId: 'different' }, + { ...cursor, table: 'outputs' }, + { ...cursor, after: [] }, + { ...cursor, after: [0] }, + { ...cursor, after: [-1] }, + { ...cursor, after: [1.1] }, + { ...cursor, after: ['1'] }, + { ...cursor, after: [Number.MAX_SAFE_INTEGER + 1] }, + { ...cursor, after: [1, 2] }, + { ...cursor, after: {} } + ]) + await expect(view.readPage('txLabels', invalid as WalletSnapshotCursor)).rejects.toThrow('cursor') + expect(read).not.toHaveBeenCalled() + const mutable = { ...cursor!, after: [...cursor!.after] } + const pending = view.readPage('txLabels', mutable) + mutable.after[0] = 99999 + expect((await pending).rows).toHaveLength(2) + await view.close() +}) + +test('unsupported providers and invalid identities refuse explicitly and failed opening releases its slot', async () => { + const idb = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + expect(idb.supportsWalletReadSnapshot()).toBe(false) + await expect(idb.openWalletReadSnapshot(identity)).rejects.toThrow('not supported') + const { source } = await fixture() + expect(source.supportsWalletReadSnapshot()).toBe(true) + const read = jest.spyOn(source, 'openReadSnapshot') + for (const key of [ + '', + '04' + '11'.repeat(32), + '02' + 'gg'.repeat(32), + 'x' + identity, + identity + 'x', + { toString: () => identity } as unknown as string + ]) { + await expect(source.openWalletReadSnapshot(key)).rejects.toThrow('identityKey') + } + expect(read).not.toHaveBeenCalled() + await expect(source.openWalletReadSnapshot('02' + '33'.repeat(32))).rejects.toThrow('existing wallet profile') + const view = await source.openWalletReadSnapshot(identity) + await view.close() + jest.spyOn(source, 'supportsRetainedReadSnapshot').mockReturnValue(false) + expect(source.supportsWalletReadSnapshot()).toBe(false) + await expect(source.openWalletReadSnapshot(identity)).rejects.toThrow('not supported') +}) + +async function seedClosure(source: StorageKnex, userId: number, otherId: number): Promise { + const k = source.knex + await k('output_baskets').del() + for (const id of [1, 2, 3]) { + await k('proven_txs').insert({ + ...timestamp, + provenTxId: id, + txid: String(id).repeat(64), + height: id, + index: 0, + merklePath: Buffer.from([id, 0, 255]), + rawTx: Buffer.from([id, 1, 255]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + await k('transactions').insert({ + ...timestamp, + transactionId: id, + userId: id === 2 ? otherId : userId, + provenTxId: id === 3 ? null : id, + status: 'completed', + reference: `tx-${id}`, + isOutgoing: true, + satoshis: 0, + description: `tx-${id}`, + txid: String(id).repeat(64), + rawTx: Buffer.from([id, 2, 255]), + inputBEEF: Buffer.from([id, 3, 255]) + }) + await k('proven_tx_reqs').insert({ + ...timestamp, + provenTxReqId: id, + provenTxId: id, + txid: String(id).repeat(64), + status: 'completed', + attempts: 0, + notified: true, + history: '{}', + notify: '{}', + rawTx: Buffer.from([id, 4, 255]), + wasBroadcast: true + }) + await k('output_baskets').insert({ + ...timestamp, + basketId: id, + userId: id === 2 ? otherId : userId, + name: `basket-${id}`, + isDeleted: id === 3 + }) + await k('outputs').insert({ + ...timestamp, + outputId: id, + userId: id === 2 ? otherId : userId, + transactionId: id, + basketId: id, + spendable: false, + change: true, + vout: 0, + satoshis: 1, + providedBy: 'you', + purpose: '', + type: 'P2PKH', + lockingScript: Buffer.from([id, 5, 255]) + }) + await k('commissions').insert({ + ...timestamp, + commissionId: id, + userId: id === 2 ? otherId : userId, + transactionId: id, + satoshis: 0, + keyOffset: 'offset', + isRedeemed: true, + lockingScript: Buffer.from([id, 6, 255]) + }) + await k('output_tags').insert({ + ...timestamp, + outputTagId: id, + userId: id === 2 ? otherId : userId, + tag: `tag-${id}`, + isDeleted: id === 3 + }) + await k('output_tags_map').insert({ ...timestamp, outputTagId: id, outputId: id, isDeleted: id === 3 }) + await k('tx_labels').insert({ + ...timestamp, + txLabelId: id, + userId: id === 2 ? otherId : userId, + label: `label-${id}`, + isDeleted: id === 3 + }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: id, transactionId: id, isDeleted: id === 3 }) + await k('certificates').insert({ + ...timestamp, + certificateId: id, + userId: id === 2 ? otherId : userId, + serialNumber: `serial-${id}`, + type: 'type', + certifier: identity, + subject: identity, + revocationOutpoint: 'a'.repeat(64) + '.0', + signature: 'signature', + isDeleted: id === 3 + }) + for (const fieldName of ['a', 'Z', 'é', '😀']) + await k('certificate_fields').insert({ + ...timestamp, + certificateId: id, + userId: id === 2 ? otherId : userId, + fieldName, + fieldValue: `value-${id}`, + masterKey: 'key' + }) + await k('sync_states').insert({ + ...timestamp, + syncStateId: id, + userId: id === 2 ? otherId : userId, + storageIdentityKey: `peer-${id}`, + storageName: `peer-${id}`, + status: 'unknown', + init: true, + refNum: `state-${id}`, + syncMap: '{}', + when: date + }) + } + // Composite positions must handle repeated first keys and preserve deleted mappings. + await k('output_tags_map').insert({ ...timestamp, outputTagId: 1, outputId: 3, isDeleted: true }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: 1, transactionId: 3, isDeleted: true }) +} + +test('all thirteen tables retain original rows, packed binary and composite key order without foreign profiles', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + const view = await source.openWalletReadSnapshot(identity) + const cases: Array<[WalletSnapshotTable, string, number[]]> = [ + ['provenTxs', 'provenTxId', [1, 3]], + ['provenTxReqs', 'provenTxReqId', [1, 3]], + ['outputBaskets', 'basketId', [1, 3]], + ['transactions', 'transactionId', [1, 3]], + ['outputs', 'outputId', [1, 3]], + ['commissions', 'commissionId', [1, 3]], + ['outputTags', 'outputTagId', [1, 3]], + ['txLabels', 'txLabelId', [1, 3]], + ['certificates', 'certificateId', [1, 3]], + ['syncStates', 'syncStateId', [1, 3]] + ] + for (const [table, key, ids] of cases) { + const rows = (await all(view, table)) as unknown as Array> + expect(rows.map(row => row[key])).toEqual(ids) + for (const row of rows) { + expect(row.created_at).toEqual(new Date(date)) + expect(row.updated_at).toEqual(new Date(date)) + if ('userId' in row) expect(row.userId).toBe(userId) + for (const flag of [ + 'isDeleted', + 'isOutgoing', + 'isRedeemed', + 'spendable', + 'change', + 'notified', + 'wasBroadcast', + 'init' + ]) { + if (flag in row) expect(typeof row[flag]).toBe('boolean') + } + for (const binary of ['rawTx', 'inputBEEF', 'merklePath', 'lockingScript']) { + if (row[binary] !== undefined) { + expect(row[binary]).toBeInstanceOf(Uint8Array) + expect(Array.isArray(row[binary])).toBe(false) + expect((row[binary] as Uint8Array)[2]).toBe(255) + expect((row[binary] as Uint8Array).buffer.byteLength).toBe((row[binary] as Uint8Array).byteLength) + } + } + } + } + expect((await all(view, 'txLabelMaps')).map(row => [row.txLabelId, row.transactionId, row.isDeleted])).toEqual([ + [1, 1, false], + [1, 3, true], + [3, 3, true] + ]) + expect((await all(view, 'outputTagMaps')).map(row => [row.outputTagId, row.outputId, row.isDeleted])).toEqual([ + [1, 1, false], + [1, 3, true], + [3, 3, true] + ]) + expect((await all(view, 'certificateFields')).map(row => [row.fieldName, row.certificateId])).toEqual( + ['Z', 'a', 'é', '😀'].flatMap(field => [ + [field, 1], + [field, 3] + ]) + ) + const states = await view.readPage('syncStates') + expect(states.rows[0].init).toBe(true) + expect(states.rows[0].when).toEqual(new Date(date)) + expect(states.rows[0].syncMap).toBe('{}') + const proof = await view.readPage('provenTxs') + proof.rows[0].rawTx[0] = 99 + expect((await view.readPage('provenTxs')).rows[0].rawTx[0]).toBe(1) + await view.close() +}) + +test.each(['txLabelMaps', 'outputTagMaps', 'certificateFields'] as const)( + 'inconsistent owned relationships reject instead of leaking or silently dropping a row: %s', + async table => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + if (table === 'txLabelMaps') + await source.knex('tx_labels_map').insert({ ...timestamp, txLabelId: 1, transactionId: 2 }) + if (table === 'outputTagMaps') + await source.knex('output_tags_map').insert({ ...timestamp, outputTagId: 1, outputId: 2 }) + if (table === 'certificateFields') + await source.knex('certificate_fields').where({ certificateId: 2 }).update({ userId }) + const view = await source.openWalletReadSnapshot(identity) + await expect(view.readPage(table)).rejects.toThrow('does not belong') + await expect(view.closed).rejects.toThrow('does not belong') + } +) + +test('certificate keys preserve empty, embedded-NUL and 100-code-point names with bounded cursor validation', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + const names = ['', 'a\0x', 'a\0y', '😀'.repeat(100)] + for (const fieldName of names) + await source.knex('certificate_fields').insert({ + ...timestamp, + userId, + certificateId: 1, + fieldName, + fieldValue: 'value', + masterKey: 'key' + }) + const view = await source.openWalletReadSnapshot(identity) + const rows = await all(view, 'certificateFields') + for (const name of names) expect(rows.some(row => row.fieldName === name)).toBe(true) + expect(rows).toHaveLength(12) + for (const fieldName of [100, 'a'.repeat(101), '😀'.repeat(101)]) { + await expect( + view.readPage('certificateFields', { + version: 1, + snapshotId: view.snapshotId, + table: 'certificateFields', + after: [fieldName, 1] + }) + ).rejects.toThrow('cursor') + } + await view.close() +}) + +test.each(['a'.repeat(101), 'a'.repeat(401)])( + 'oversized stored certificate key rejects without loading its row (%#)', + async fieldName => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + await source.knex('certificate_fields').del() + await source + .knex('certificate_fields') + .insert({ ...timestamp, userId, certificateId: 1, fieldName, fieldValue: 'x'.repeat(500000), masterKey: 'key' }) + const view = await source.openWalletReadSnapshot(identity) + const queries: string[] = [] + source.knex.on('query', q => queries.push(q.sql)) + await expect(view.readPage('certificateFields', undefined, { maxBytes: 16777216 })).rejects.toThrow('cursor') + await expect(view.closed).rejects.toThrow('cursor') + expect(queries.some(sql => sql.includes('`certificate_fields`.*'))).toBe(false) + } +) + +test('empty tables, maximum limits and caller cancellation preserve cleanup semantics', async () => { + const { source } = await fixture() + const controller = new AbortController() + const view = await source.openWalletReadSnapshot(identity, { signal: controller.signal }) + expect(await view.readPage('txLabels', undefined, { maxRows: 1000, maxBytes: 16777216 })).toEqual({ + rows: [], + payloadBytes: 0, + done: true, + cursor: undefined + }) + controller.abort() + await expect(view.readPage('txLabels')).rejects.toThrow('cancelled') + await view.closed + const fresh = await source.openWalletReadSnapshot(identity) + await fresh.close() +}) + +test('concurrent reads refuse instead of queueing and expiry invalidates cursors', async () => { + const { source, userId } = await fixture() + await labels(source, userId, 3) + const view = await source.openWalletReadSnapshot(identity, { lifetimeMs: 10000 }) + const reading = view.readPage('txLabels', undefined, { maxRows: 1 }) + await expect(view.readPage('txLabels')).rejects.toThrow('read in flight') + const first = await reading + jest.spyOn(Date, 'now').mockReturnValue(view.expiresAt) + await expect(view.readPage('txLabels', first.cursor)).rejects.toThrow('expired') + await view.closed +}) + +test('existing SQL keys support forward seeks for numeric and composite pages without OFFSET or full counts', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + const view = await source.openWalletReadSnapshot(identity) + const requests: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] + const collect = (q: { sql: string; bindings: Knex.RawBinding[] }) => { + if (q.sql.startsWith('select')) requests.push(q) + } + source.knex.on('query', collect) + for (const table of ['txLabels', 'txLabelMaps', 'certificateFields'] as const) { + const first = await view.readPage(table, undefined, { maxRows: 1 }) + await view.readPage(table, first.cursor, { maxRows: 1 }) + } + source.knex.removeListener('query', collect) + await view.close() + const subsequent = requests.filter(q => q.sql.includes(' > ')) + expect(subsequent).toHaveLength(6) + for (const query of subsequent) { + expect(query.sql).not.toMatch(/offset|count\(/i) + const plan = await source.knex.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) + expect(plan.map((row: { detail: string }) => row.detail).join('\n')).toMatch( + /SEARCH (tx_labels|tx_labels_map|certificate_fields) USING .*\(.*>\(?\?/ + ) + } +}) + +test('an owned certificate with a foreign-user field rejects without returning that field', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + await source.knex('certificate_fields').where({ certificateId: 1, fieldName: 'a' }).update({ userId: otherId }) + const view = await source.openWalletReadSnapshot(identity) + await expect(view.readPage('certificateFields')).rejects.toThrow('does not belong') + await expect(view.closed).rejects.toThrow('does not belong') +}) + +test.each(['txLabelMaps', 'outputTagMaps'] as const)( + 'a foreign label/tag linked to an owned parent also rejects: %s', + async table => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + if (table === 'txLabelMaps') + await source.knex('tx_labels_map').insert({ ...timestamp, txLabelId: 2, transactionId: 1 }) + else await source.knex('output_tags_map').insert({ ...timestamp, outputTagId: 2, outputId: 1 }) + const view = await source.openWalletReadSnapshot(identity) + await expect(view.readPage(table)).rejects.toThrow('does not belong') + await expect(view.closed).rejects.toThrow('does not belong') + } +) + +test('a page query failure is observed through read and cleanup before the provider can open a fresh view', async () => { + const { source, userId } = await fixture() + await labels(source, userId, 1) + const failure = new Error('Synthetic page query failure') + const rejectPage = (query: { sql: string }): void => { + if (query.sql.includes('__snapshotBytes')) throw failure + } + source.knex.on('query', rejectPage) + const view = await source.openWalletReadSnapshot(identity) + await expect(view.readPage('txLabels')).rejects.toBe(failure) + await expect(view.closed).rejects.toBe(failure) + expect(view.isOpen).toBe(false) + source.knex.removeListener('query', rejectPage) + const fresh = await source.openWalletReadSnapshot(identity) + expect((await fresh.readPage('txLabels')).rows).toHaveLength(1) + await fresh.close() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts new file mode 100644 index 000000000..30c904d29 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -0,0 +1,313 @@ +import { Random, Utils } from '@bsv/sdk' +import type { Knex } from 'knex' +import type { StorageKnex } from '../StorageKnex' +import type { TrxToken } from '../../sdk/WalletStorage.interfaces' +import { WERR_INVALID_PARAMETER, WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { + PackedSnapshotRow, + WalletReadSnapshot, + WalletReadSnapshotOptions, + WalletSnapshotCursor, + WalletSnapshotPage, + WalletSnapshotPageLimits, + WalletSnapshotTable, + WalletSnapshotTables +} from './WalletReadSnapshot' + +interface TableDefinition { + name: string + keys: string[] + booleans?: string[] + dates?: string[] +} + +// Use existing unique keys. This additive path does not add indexes or change +// traversal order for legacy OFFSET checkpoints. Map/field order is explicitly +// storage-key order, not the canonical BRC-38 array order. +const definitions: Record = { + provenTxs: { name: 'proven_txs', keys: ['provenTxId'] }, + provenTxReqs: { name: 'proven_tx_reqs', keys: ['provenTxReqId'], booleans: ['notified', 'wasBroadcast'] }, + outputBaskets: { name: 'output_baskets', keys: ['basketId'], booleans: ['isDeleted'] }, + transactions: { name: 'transactions', keys: ['transactionId'], booleans: ['isOutgoing'] }, + commissions: { name: 'commissions', keys: ['commissionId'], booleans: ['isRedeemed'] }, + outputs: { name: 'outputs', keys: ['outputId'], booleans: ['spendable', 'change'] }, + outputTags: { name: 'output_tags', keys: ['outputTagId'], booleans: ['isDeleted'] }, + outputTagMaps: { name: 'output_tags_map', keys: ['outputTagId', 'outputId'], booleans: ['isDeleted'] }, + txLabels: { name: 'tx_labels', keys: ['txLabelId'], booleans: ['isDeleted'] }, + txLabelMaps: { name: 'tx_labels_map', keys: ['txLabelId', 'transactionId'], booleans: ['isDeleted'] }, + certificates: { name: 'certificates', keys: ['certificateId'], booleans: ['isDeleted'] }, + certificateFields: { name: 'certificate_fields', keys: ['fieldName', 'certificateId'] }, + syncStates: { name: 'sync_states', keys: ['syncStateId'], booleans: ['init'], dates: ['when'] } +} + +function definition(table: WalletSnapshotTable): TableDefinition { + if (!Object.hasOwn(definitions, table)) throw new WERR_INVALID_PARAMETER('table', 'a wallet snapshot table') + return definitions[table] +} + +function bound(value: number | undefined, fallback: number, ceiling: number, name: string): number { + const result = value ?? fallback + if (!Number.isSafeInteger(result) || result < 1 || result > ceiling) { + throw new WERR_INVALID_PARAMETER(name, `an integer from 1 to ${ceiling}`) + } + return result +} + +function position( + cursor: WalletSnapshotCursor | undefined, + snapshotId: string, + table: WalletSnapshotTable, + keys: string[] +): Array | undefined { + if (cursor === undefined) return undefined + if ( + cursor === null || + cursor.version !== 1 || + cursor.snapshotId !== snapshotId || + cursor.table !== table || + !Array.isArray(cursor.after) || + cursor.after.length !== keys.length || + cursor.after.some((value, index) => + keys[index] === 'fieldName' + ? typeof value !== 'string' || value.length > 200 || Array.from(value).length > 100 + : !Number.isSafeInteger(value) || (value as number) < 1 + ) + ) { + throw new WERR_INVALID_PARAMETER('cursor', 'a version-one position for this snapshot and table') + } + // Detach before the asynchronous database boundary. + return [...cursor.after] +} + +/** Lexicographic seek, using the same database collation as ORDER BY. */ +function seek(query: Knex.QueryBuilder, keys: string[], after: Array, inclusive = false): void { + void query.whereRaw( + `(${keys.map(() => '??').join(', ')}) ${inclusive ? '<=' : '>'} (${keys.map(() => '?').join(', ')})`, + [...keys, ...after] + ) +} + +function owned(k: Knex, table: string, id: string, source: string, userId: number): Knex.QueryBuilder { + return k(table) + .select(k.raw('?', [1])) + .where(`${table}.userId`, userId) + .whereRaw('?? = ??', [`${table}.${id}`, source]) +} + +function scopedQuery(k: Knex, table: WalletSnapshotTable, userId: number): Knex.QueryBuilder { + const { name } = definitions[table] + const query = k(name) + if (table === 'provenTxReqs') { + return query.whereExists(owned(k, 'transactions', 'txid', `${name}.txid`, userId)) + } + if (table === 'provenTxs') { + // Include proofs referenced only by a request as well as transaction proofs. + return query.where(function () { + void this.whereExists(owned(k, 'transactions', 'provenTxId', `${name}.provenTxId`, userId)).orWhereExists( + k('proven_tx_reqs') + .select(k.raw('?', [1])) + .whereRaw('?? = ??', ['proven_tx_reqs.provenTxId', `${name}.provenTxId`]) + .whereExists(owned(k, 'transactions', 'txid', 'proven_tx_reqs.txid', userId)) + ) + }) + } + if (table === 'txLabelMaps') { + return query.where(function () { + void this.whereExists(owned(k, 'tx_labels', 'txLabelId', `${name}.txLabelId`, userId)).orWhereExists( + owned(k, 'transactions', 'transactionId', `${name}.transactionId`, userId) + ) + }) + } + if (table === 'outputTagMaps') { + return query.where(function () { + void this.whereExists(owned(k, 'output_tags', 'outputTagId', `${name}.outputTagId`, userId)).orWhereExists( + owned(k, 'outputs', 'outputId', `${name}.outputId`, userId) + ) + }) + } + if (table === 'certificateFields') { + return query.where(function () { + void this.where(`${name}.userId`, userId).orWhereExists( + owned(k, 'certificates', 'certificateId', `${name}.certificateId`, userId) + ) + }) + } + return query.where(`${name}.userId`, userId) +} + +function normalize(storage: StorageKnex, row: Record, schema: TableDefinition): T { + for (const key of Object.keys(row)) { + const value = row[key] + if (value === null) row[key] = undefined + else if (key === 'created_at' || key === 'updated_at' || schema.dates?.includes(key) === true) { + row[key] = storage.validateDate(value as Date) + } else if (schema.booleans?.includes(key) === true) row[key] = value !== 0 && value !== false + else if (value instanceof Uint8Array) row[key] = new Uint8Array(value) + } + return row as T +} + +function charge(k: Knex, columns: string[], mysql: boolean): Knex.Raw { + const length = mysql ? 'octet_length(??)' : 'length(cast(?? as blob))' + return k.raw(`(${columns.map(() => `2 * coalesce(${length}, 0) + 64`).join(' + ')}) as ??`, [ + ...columns, + '__snapshotBytes' + ]) +} + +function keyColumn(k: Knex, key: string, mysql: boolean): string | Knex.Raw { + if (key !== 'fieldName') return key + const length = mysql ? 'octet_length(??)' : 'length(cast(?? as blob))' + // Preserve all characters, including embedded NUL and astral code points. + // SQLite substr/length(text) stop at NUL. Bound bytes in SQL before loading + // this key, then validate its 100-character schema limit in the cursor parser. + return k.raw(`case when ${length} <= 400 then ?? end as ??`, [key, key, key]) +} + +function relationGuard(k: Knex, table: WalletSnapshotTable, userId: number): Knex.Raw { + const name = definitions[table].name + const relations: Partial>> = { + txLabelMaps: [ + ['tx_labels', 'txLabelId'], + ['transactions', 'transactionId'] + ], + outputTagMaps: [ + ['output_tags', 'outputTagId'], + ['outputs', 'outputId'] + ], + certificateFields: [ + ['certificates', 'certificateId'], + ['users', 'userId'] + ] + } + const relation = relations[table] + if (relation === undefined) return k.raw('1 as ??', ['__snapshotOwned']) + return k.raw(`(${relation.map(() => 'exists ?').join(' and ')}) as ??`, [ + ...relation.map(([target, id]) => owned(k, target, id, `${name}.${id}`, userId)), + '__snapshotOwned' + ]) +} + +async function columnNames(k: Knex, table: string, mysql: boolean): Promise { + if (!mysql) return Object.keys(await k(table).columnInfo()) + // Knex's columnInfo binds client.config.connection.database, which is absent + // with an externally supplied mysql2 pool. Inspect this retained connection's + // actual database instead, with a bounded metadata result. + const [rows]: Array> = await k.raw( + 'SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION LIMIT 65', + [table] + ) + return rows.map(row => row.name) +} + +async function readPage( + storage: StorageKnex, + trx: TrxToken, + userId: number, + table: T, + after: Array | undefined, + limits: { maxRows: number; maxBytes: number }, + columns: Map, + snapshotId: string +): Promise> { + const k = storage.toDb(trx) + const schema = definitions[table] + let fields = columns.get(table) + if (fields === undefined) { + fields = await columnNames(k, schema.name, storage.dbtype === 'MySQL') + if (fields.length === 0 || fields.length > 64) throw new WERR_INVALID_OPERATION('Unsupported snapshot schema') + columns.set(table, fields) + } + const base = (): Knex.QueryBuilder => { + const q = scopedQuery(k, table, userId) + if (after !== undefined) seek(q, schema.keys, after) + for (const key of schema.keys) void q.orderBy(key) + return q + } + const keyColumns = schema.keys.map(key => keyColumn(k, key, storage.dbtype === 'MySQL')) + // Only bounded keys and lengths cross the driver boundary in this pass. A + // giant blob/history must not be loaded merely to discover it exceeds budget. + const candidates: Array> = await base() + .select(...keyColumns, charge(k, fields, storage.dbtype === 'MySQL'), relationGuard(k, table, userId)) + .limit(limits.maxRows) + let payloadBytes = 0 + let count = 0 + for (const candidate of candidates) { + if (Number(candidate.__snapshotOwned) !== 1) { + throw new WERR_INVALID_OPERATION('Snapshot relation does not belong to this wallet profile') + } + const bytes = Number(candidate.__snapshotBytes) + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new WERR_INVALID_OPERATION('Invalid snapshot row size') + if (payloadBytes + bytes > limits.maxBytes) { + if (count === 0) + throw new WERR_INVALID_OPERATION('Snapshot row exceeds maxBytes; large-value streaming is required') + break + } + payloadBytes += bytes + count++ + } + const last = candidates[count - 1] + const cursor: WalletSnapshotCursor | undefined = + last === undefined ? undefined : { version: 1, snapshotId, table, after: schema.keys.map(key => last[key]) } + // Validate database keys too, including SQLite strings without enforced SQL length. + const end = position(cursor, snapshotId, table, schema.keys) + let rows: Array> = [] + if (end !== undefined) { + const query = base().select(`${schema.name}.*`).limit(count) + seek(query, schema.keys, end, true) + const raw: Array> = await query + rows = raw.map(row => normalize(storage, row, schema)) + } + return { rows, payloadBytes, cursor, done: count === candidates.length && candidates.length < limits.maxRows } +} + +/** SQL implementation, deliberately separate from legacy OFFSET sync and public RPC. */ +export async function openKnexWalletReadSnapshot( + storage: StorageKnex, + identityKey: string, + options: WalletReadSnapshotOptions +): Promise { + if (typeof identityKey !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(identityKey)) { + throw new WERR_INVALID_PARAMETER('identityKey', 'a compressed public identity key') + } + const view = await storage.openReadSnapshot(options) + try { + const header = await view.read(async trx => { + const sourceStorage = await storage.readSettings(trx) + const user = await storage.findUserByIdentityKey(identityKey, trx) + if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') + return { sourceStorage, user } + }) + const userId = header.user.userId + const snapshotId = Utils.toHex(Random(32)) + const columns = new Map() + return { + version: 1, + snapshotId, + ...header, + expiresAt: view.expiresAt, + get isOpen() { + return view.isOpen + }, + closed: view.closed, + close: view.close, + async readPage( + table: T, + cursor?: WalletSnapshotCursor, + limits: WalletSnapshotPageLimits = {} + ): Promise> { + const schema = definition(table) + const after = position(cursor, snapshotId, table, schema.keys) + const maxRows = bound(limits.maxRows, 128, 1000, 'maxRows') + const maxBytes = bound(limits.maxBytes, 262144, 16777216, 'maxBytes') + return await view.read(trx => + readPage(storage, trx, userId, table, after, { maxRows, maxBytes }, columns, snapshotId) + ) + } + } + } catch (error) { + // Keep the opening error authoritative while still awaiting physical cleanup. + await view.close().catch(() => undefined) + throw error + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts index fb8e8f5c0..a95c329e2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts @@ -1,4 +1,11 @@ import fc from 'fast-check' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import type { WalletSnapshotCursor } from './WalletReadSnapshot' import { runInSeries } from '../../utility/runInSeries' import { retainReadSnapshot } from './RetainedReadSnapshot' @@ -37,6 +44,82 @@ afterEach(() => { jest.useRealTimers() }) +test('random profile rows and page budgets preserve the pinned keyset under independent writes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'wallet-page-property-')) + const open = () => + new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + }) + const source = open() + const writer = open() + const identity = '02' + '11'.repeat(32) + try { + await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate('property source', 'property-storage') + await source.makeAvailable() + const { user } = await source.findOrInsertUser(identity) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await writer.makeAvailable() + await fc.assert( + fc.asyncProperty( + fc.array(fc.record({ foreign: fc.boolean(), deleted: fc.boolean(), length: fc.integer({ min: 0, max: 80 }) }), { + maxLength: 40 + }), + fc.integer({ min: 1, max: 12 }), + fc.integer({ min: 1100, max: 8192 }), + async (entries, maxRows, maxBytes) => { + await source.knex('tx_labels').del() + const when = new Date('2026-01-01T00:00:00.000Z') + const records = entries.map((entry, index) => ({ + txLabelId: index + 1, + userId: entry.foreign ? other.userId : user.userId, + label: `${index}-${'é'.repeat(entry.length)}`, + isDeleted: entry.deleted, + created_at: when, + updated_at: when + })) + if (records.length > 0) + await source + .knex('tx_labels') + .insert(records.map(row => ({ ...row, created_at: when.toISOString(), updated_at: when.toISOString() }))) + const expected = records.filter(row => row.userId === user.userId) + const view = await source.openWalletReadSnapshot(identity) + try { + await writer.knex('tx_labels').where({ userId: user.userId }).update({ isDeleted: true }) + await writer.findOrInsertTxLabel(user.userId, 'post-snapshot insert') + const actual: typeof expected = [] + let cursor: WalletSnapshotCursor | undefined + for (let pages = 0; ; pages++) { + expect(pages).toBeLessThanOrEqual(expected.length + 1) + const page = await view.readPage('txLabels', cursor, { maxRows, maxBytes }) + expect(page.rows.length).toBeLessThanOrEqual(maxRows) + expect(page.payloadBytes).toBeLessThanOrEqual(maxBytes) + expect(await view.readPage('txLabels', cursor, { maxRows, maxBytes })).toEqual(page) + actual.push(...page.rows) + if (page.done) break + expect(page.rows.length).toBeGreaterThan(0) + cursor = page.cursor + } + expect(actual).toEqual(expected) + expect(new Set(actual.map(row => row.txLabelId)).size).toBe(expected.length) + } finally { + await view.close() + } + } + ) + ) + } finally { + await Promise.all([source.destroy(), writer.destroy()]) + await rm(directory, { recursive: true, force: true }) + } +}) + test('random schedules preserve single-read admission, late-result rejection and physical cleanup ownership', async () => { jest.useFakeTimers() await fc.assert( diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/WalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/WalletReadSnapshot.ts new file mode 100644 index 000000000..47b9d90e8 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/WalletReadSnapshot.ts @@ -0,0 +1,78 @@ +import type * as tables from '../schema/tables' +import type { RetainedReadSnapshotOptions } from './RetainedReadSnapshot' + +/** Version-one local row contract. Binary columns never expand into number arrays. */ +export type PackedSnapshotRow = { [K in keyof T]: PackedSnapshotValue } +type PackedSnapshotValue = T extends number[] ? Uint8Array : T + +export interface WalletSnapshotTables { + provenTxs: tables.TableProvenTx + provenTxReqs: tables.TableProvenTxReq + outputBaskets: tables.TableOutputBasket + transactions: tables.TableTransaction + commissions: tables.TableCommission + outputs: tables.TableOutput + outputTags: tables.TableOutputTag + outputTagMaps: tables.TableOutputTagMap + txLabels: tables.TableTxLabel + txLabelMaps: tables.TableTxLabelMap + certificates: tables.TableCertificate + certificateFields: tables.TableCertificateField + syncStates: tables.TableSyncState +} + +export type WalletSnapshotTable = keyof WalletSnapshotTables + +/** A position within this live local view, not a durable checkpoint or authorization credential. */ +export interface WalletSnapshotCursor { + readonly version: 1 + readonly snapshotId: string + readonly table: WalletSnapshotTable + readonly after: ReadonlyArray +} + +export interface WalletSnapshotPageLimits { + /** Defaults to 128. Integer from 1 to 1,000. */ + maxRows?: number + /** + * Defaults to 262,144. Integer from 1 to 16,777,216. The SQL preflight charges + * twice each cell's stored byte length plus 64 bytes per cell before fetching + * payloads. This is a payload allocation budget, not encoded wire size or RSS. + * An individual row over the budget rejects; it is never silently omitted. + */ + maxBytes?: number +} + +export interface WalletSnapshotPage { + rows: Array> + /** Preflight charge for the returned rows. */ + payloadBytes: number + /** True only when this table's retained view has been exhausted. */ + done: boolean + /** Last returned position, usable for retry or continuation only in this view. */ + cursor?: WalletSnapshotCursor +} + +export interface WalletReadSnapshot { + readonly version: 1 + readonly snapshotId: string + readonly sourceStorage: tables.TableSettings + readonly user: tables.TableUser + readonly expiresAt: number + readonly isOpen: boolean + readonly closed: Promise + /** + * One read at a time. Repeating a cursor and limits repeats the same page. + * Rows include tombstones and stored values; no operational policy is applied. + * This local interface is not exposed over RPC and does not validate a complete + * portable document. Settings, user and every page share the same read view. + */ + readPage: ( + table: T, + cursor?: WalletSnapshotCursor, + limits?: WalletSnapshotPageLimits + ) => Promise> + close: () => Promise +} + +export type WalletReadSnapshotOptions = RetainedReadSnapshotOptions diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index fd4718849..d29343c0e 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -84,8 +84,17 @@ absent from the RPC allowlist. Each view occupies one pool connection; query cancellation/deadlines remain a driver concern. Retention is explicitly unsupported on IndexedDB. No persisted schema or index transition is introduced. -These checkpoints are only part of S2/P1. They do not yet implement a bounded -keyset API, authenticated remote views, durable source-view checkpoints, +The subsequent local SQL page contract binds one profile and source metadata to +the retained view, returns all thirteen standard tables through keyset cursors, +and preflights payload sizes before fetching rows. Binary fields remain packed. +Retries within a view repeat pages; a new view rejects its predecessor's cursor. +Existing unique keys avoid changing legacy OFFSET traversal. Composite storage +order is explicit and differs from canonical archive order. This provides +bounded local pages, with explicit oversized-row refusal pending streaming. + +These checkpoints are only part of S2/P1/S4. They do not yet implement indexed +identity/update predicates and commit-order high-water positions, authenticated +remote views, durable source-view checkpoints, streaming, bounded push/backup work or staged restore. IndexedDB writers wait during capture and the legacy helpers still materialize the document. A local MySQL 8.4.11 fixture confirms repeatable-read capture under From 1a611ee243d0ca0249b2ecf1865bf509a0fc9928 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 21:06:05 -0700 Subject: [PATCH 036/127] docs(deps): link verified brace-expansion source review --- governance/repository-health/exceptions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index e2bda487f..e31e8ddd7 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -90,7 +90,7 @@ "https://github.com/advisories/GHSA-qhr7-859c-m2p7", "https://github.com/advisories/GHSA-6j4f-fj2g-mc7p", "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr", - "https://github.com/juliangruber/brace-expansion/releases/tag/v5.0.12" + "https://github.com/juliangruber/brace-expansion/compare/v5.0.9...v5.0.12" ], "created": "2026-07-25", "reviewBy": "2026-10-30", @@ -233,7 +233,7 @@ "https://github.com/advisories/GHSA-qhr7-859c-m2p7", "https://github.com/advisories/GHSA-6j4f-fj2g-mc7p", "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr", - "https://github.com/juliangruber/brace-expansion/releases/tag/v5.0.12" + "https://github.com/juliangruber/brace-expansion/compare/v5.0.9...v5.0.12" ], "created": "2026-07-25", "reviewBy": "2026-10-30", From 85569303a1a2aade03d6f48a14a68b2c76bec08e Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 21:22:11 -0700 Subject: [PATCH 037/127] refactor(wallet): simplify snapshot page internals --- .../snapshot/KnexWalletReadSnapshot.ts | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index 30c904d29..f2538b59e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -61,8 +61,7 @@ function position( ): Array | undefined { if (cursor === undefined) return undefined if ( - cursor === null || - cursor.version !== 1 || + cursor?.version !== 1 || cursor.snapshotId !== snapshotId || cursor.table !== table || !Array.isArray(cursor.after) || @@ -149,10 +148,8 @@ function normalize(storage: StorageKnex, row: Record, schema function charge(k: Knex, columns: string[], mysql: boolean): Knex.Raw { const length = mysql ? 'octet_length(??)' : 'length(cast(?? as blob))' - return k.raw(`(${columns.map(() => `2 * coalesce(${length}, 0) + 64`).join(' + ')}) as ??`, [ - ...columns, - '__snapshotBytes' - ]) + const cellCharge = `2 * coalesce(${length}, 0) + 64` + return k.raw(`(${columns.map(() => cellCharge).join(' + ')}) as ??`, [...columns, '__snapshotBytes']) } function keyColumn(k: Knex, key: string, mysql: boolean): string | Knex.Raw { @@ -200,16 +197,45 @@ async function columnNames(k: Knex, table: string, mysql: boolean): Promise row.name) } +interface SnapshotContext { + storage: StorageKnex + userId: number + snapshotId: string + columns: Map +} + +/** Select a complete prefix without fetching payloads or skipping oversized rows. */ +function boundedPrefix( + candidates: Array>, + maxBytes: number +): { count: number; payloadBytes: number } { + let payloadBytes = 0 + let count = 0 + for (const candidate of candidates) { + if (Number(candidate.__snapshotOwned) !== 1) { + throw new WERR_INVALID_OPERATION('Snapshot relation does not belong to this wallet profile') + } + const bytes = Number(candidate.__snapshotBytes) + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new WERR_INVALID_OPERATION('Invalid snapshot row size') + if (payloadBytes + bytes > maxBytes) { + if (count === 0) + throw new WERR_INVALID_OPERATION('Snapshot row exceeds maxBytes; large-value streaming is required') + break + } + payloadBytes += bytes + count++ + } + return { count, payloadBytes } +} + async function readPage( - storage: StorageKnex, + context: SnapshotContext, trx: TrxToken, - userId: number, table: T, after: Array | undefined, - limits: { maxRows: number; maxBytes: number }, - columns: Map, - snapshotId: string + limits: { maxRows: number; maxBytes: number } ): Promise> { + const { storage, userId, columns, snapshotId } = context const k = storage.toDb(trx) const schema = definitions[table] let fields = columns.get(table) @@ -230,22 +256,7 @@ async function readPage( const candidates: Array> = await base() .select(...keyColumns, charge(k, fields, storage.dbtype === 'MySQL'), relationGuard(k, table, userId)) .limit(limits.maxRows) - let payloadBytes = 0 - let count = 0 - for (const candidate of candidates) { - if (Number(candidate.__snapshotOwned) !== 1) { - throw new WERR_INVALID_OPERATION('Snapshot relation does not belong to this wallet profile') - } - const bytes = Number(candidate.__snapshotBytes) - if (!Number.isSafeInteger(bytes) || bytes < 0) throw new WERR_INVALID_OPERATION('Invalid snapshot row size') - if (payloadBytes + bytes > limits.maxBytes) { - if (count === 0) - throw new WERR_INVALID_OPERATION('Snapshot row exceeds maxBytes; large-value streaming is required') - break - } - payloadBytes += bytes - count++ - } + const { count, payloadBytes } = boundedPrefix(candidates, limits.maxBytes) const last = candidates[count - 1] const cursor: WalletSnapshotCursor | undefined = last === undefined ? undefined : { version: 1, snapshotId, table, after: schema.keys.map(key => last[key]) } @@ -280,7 +291,7 @@ export async function openKnexWalletReadSnapshot( }) const userId = header.user.userId const snapshotId = Utils.toHex(Random(32)) - const columns = new Map() + const context: SnapshotContext = { storage, userId, snapshotId, columns: new Map() } return { version: 1, snapshotId, @@ -300,9 +311,7 @@ export async function openKnexWalletReadSnapshot( const after = position(cursor, snapshotId, table, schema.keys) const maxRows = bound(limits.maxRows, 128, 1000, 'maxRows') const maxBytes = bound(limits.maxBytes, 262144, 16777216, 'maxBytes') - return await view.read(trx => - readPage(storage, trx, userId, table, after, { maxRows, maxBytes }, columns, snapshotId) - ) + return await view.read(trx => readPage(context, trx, table, after, { maxRows, maxBytes })) } } } catch (error) { From 09d5d8df0d0b60f4716e6349e606b057aef4dde4 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 21:31:00 -0700 Subject: [PATCH 038/127] fix(ci): classify newly added erased declarations by emitted code --- docs/reference/ci-performance.md | 6 +++ scripts/patch-coverage.mjs | 36 +++++++--------- scripts/patch-coverage.test.mjs | 70 +++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 22 deletions(-) diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index 018b72ed5..95e35fe1d 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -123,6 +123,12 @@ no privileged trigger or additional write/OIDC permission is granted. The upload processing check and final notifications run for every nonempty report, so the advisory `codecov/patch` report is available for contributors as well as maintainers. The repository-owned 90% patch-coverage check is mandatory on the exact diff. +TypeScript classification compares JavaScript emitted by the locked compiler. +A new declaration-only file is compared with empty source only after Git's base +tree proves the path was absent. Runtime code, enums and side-effect imports +remain governed; unsupported syntax remains governed, and Git/read errors fail +the gate. This avoids an impossible LCOV obligation for erased declarations +without adding path exclusions or reducing the coverage floor. The repository ruleset must require `merge-gate` and must not require the duplicate external `codecov/patch` status. Uploading, processing and notification run in a separate reporting job after the local gate, so an external service outage cannot diff --git a/scripts/patch-coverage.mjs b/scripts/patch-coverage.mjs index 59d693110..78c2208b0 100644 --- a/scripts/patch-coverage.mjs +++ b/scripts/patch-coverage.mjs @@ -197,30 +197,24 @@ export function omitStaticMarkdownModules(changed, readSource) { } } -function omitTypeOnlyChanges(changed, base) { - const mergeBase = execFileSync('/usr/bin/git', ['merge-base', base, 'HEAD'], { - cwd: REPOSITORY_ROOT, - encoding: 'utf8' - }).trim() - for (const file of changed.keys()) { - if (!/\.[cm]?ts$/.test(file)) continue - let before - try { - before = execFileSync('/usr/bin/git', ['show', `${mergeBase}:${file}`], { - cwd: REPOSITORY_ROOT, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - maxBuffer: 20 * 1024 * 1024 - }) - } catch { - // New or unreadable files remain governed. - continue - } - const after = execFileSync('/usr/bin/git', ['show', `HEAD:${file}`], { - cwd: REPOSITORY_ROOT, +export function omitTypeOnlyChanges(changed, base, repositoryRoot = REPOSITORY_ROOT) { + const git = arguments_ => + execFileSync('/usr/bin/git', arguments_, { + cwd: repositoryRoot, encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 20 * 1024 * 1024 }) + const mergeBase = git(['merge-base', base, 'HEAD']).trim() + // Only a successfully read base tree can prove that a path is new. A failed + // blob read is not evidence of an empty file: Git/read errors fail the gate. + const baseFiles = new Set( + git(['ls-tree', '-r', '--name-only', '-z', mergeBase, '--', 'packages']).split('\0') + ) + for (const file of changed.keys()) { + if (!/\.[cm]?ts$/.test(file)) continue + const before = baseFiles.has(file) ? git(['show', `${mergeBase}:${file}`]) : '' + const after = git(['show', `HEAD:${file}`]) if (!hasRuntimeChange(before, after)) changed.delete(file) } } diff --git a/scripts/patch-coverage.test.mjs b/scripts/patch-coverage.test.mjs index 0e8f060db..ce13a5f0d 100644 --- a/scripts/patch-coverage.test.mjs +++ b/scripts/patch-coverage.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { readFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' import { changedLinesFromDiff, @@ -8,6 +11,7 @@ import { hasRuntimeChange, isStaticMarkdownModule, omitStaticMarkdownModules, + omitTypeOnlyChanges, mergeLcov, runtimeComparisonAvailable } from './patch-coverage.mjs' @@ -281,3 +285,67 @@ test('the coverage aggregation job installs its locked compiler before classifyi const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) assert.equal(typeof manifest.devDependencies.esbuild, 'string') }) + +test('new erased declarations need no LCOV while runtime, unsupported syntax and unreadable Git sources stay governed', t => { + const repository = mkdtempSync(join(tmpdir(), 'patch-coverage-source-')) + t.after(() => rmSync(repository, { recursive: true, force: true })) + const git = (...args) => + execFileSync('/usr/bin/git', args, { + cwd: repository, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() + const write = (name, source) => writeFileSync(join(repository, 'packages', name), source) + git('init', '--initial-branch=main') + mkdirSync(join(repository, 'packages')) + write('existing.ts', 'export interface Options { first: string }') + git('add', 'packages') + const commit = () => + git( + '-c', + 'user.name=Coverage fixture', + '-c', + 'user.email=fixture@example.invalid', + 'commit', + '-m', + 'Synthetic source' + ) + commit() + const base = git('rev-parse', 'HEAD') + write('existing.ts', 'export interface Options { first: string; second?: number }') + write( + 'new-types.ts', + "import type * as values from './values'; export type Value = values.Value; export interface Options { value: Value }" + ) + write('new-runtime.ts', 'export const value = 1') + write('new-enum.ts', 'export enum State { Ready }') + write('new-effect.ts', 'import "./start.js"; export interface Options {}') + write('new-invalid.ts', 'invalid TypeScript {') + git('add', 'packages') + commit() + const names = [ + 'existing.ts', + 'new-types.ts', + 'new-runtime.ts', + 'new-enum.ts', + 'new-effect.ts', + 'new-invalid.ts' + ] + const changed = new Map(names.map(name => ['packages/' + name, new Set([1])])) + omitTypeOnlyChanges(changed, base, repository) + const expected = runtimeComparisonAvailable() ? names.slice(2) : names + assert.deepEqual( + [...changed.keys()], + expected.map(name => 'packages/' + name) + ) + assert.throws(() => + omitTypeOnlyChanges(new Map([['packages/missing.ts', new Set([1])]]), base, repository) + ) + assert.throws(() => + omitTypeOnlyChanges( + new Map([['packages/new-types.ts', new Set([1])]]), + 'missing-base', + repository + ) + ) +}) From 38dba47c441b7e080905afbe4edd6438326bbad7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 29 Sep 2026 21:40:08 -0700 Subject: [PATCH 039/127] fix(wab): use the approved Docker Hub base at the same digest --- infra/wab/Dockerfile | 4 ++-- infra/wab/README.md | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/infra/wab/Dockerfile b/infra/wab/Dockerfile index 7d05bc4ce..d91711358 100644 --- a/infra/wab/Dockerfile +++ b/infra/wab/Dockerfile @@ -2,7 +2,7 @@ # 1) Builder Stage: builds TypeScript and compiles native modules # ------------------------------------------------------------------------------ # Base version 24.18.0-alpine3.24; the digest is the authoritative build input. -FROM public.ecr.aws/docker/library/node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS builder +FROM node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS builder # Install build tools for native modules (sqlite3, etc.) RUN apk add --no-cache g++ make python3 @@ -25,7 +25,7 @@ RUN npm run build \ # ------------------------------------------------------------------------------ # 2) Production Stage: minimal runtime image # ------------------------------------------------------------------------------ -FROM public.ecr.aws/docker/library/node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd +FROM node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd # Strip npm from the runtime image: the app runs via `node` (see CMD) and # node:24-alpine's bundled npm ships its own HIGH-CVE deps (picomatch/undici) diff --git a/infra/wab/README.md b/infra/wab/README.md index 38ca0ccb3..1fcf4ed0e 100644 --- a/infra/wab/README.md +++ b/infra/wab/README.md @@ -5,6 +5,12 @@ Welcome to the **Wallet Authentication Backend (WAB)** project! This README prov See [Service Resource Profiles](../../docs/reference/service-resource-profiles.md) for bounded defaults, database sizing, and HPA prerequisites. +The Dockerfile uses the governed, digest-pinned Node base from Docker Hub, matching +the other TS Stack services. This preserves the reviewed image bytes while +avoiding public ECR mirror throttling during builds. Base versions, equivalent +registry references and runtime package pins remain governed by +[`governance/container-images.json`](../../governance/container-images.json). + --- ## What Is the WAB? From fa72b4a60e426dbdd8af5bfa0f68fa69adece9e7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 01:56:32 -0700 Subject: [PATCH 040/127] fix(infra): refresh available Alpine OpenSSL security pins Adapt the nine container paths reviewed in aa1de9dca53fa18411f8b729431fe86d2965ced7. The official Alpine index now provides paired libcrypto3/libssl3 3.5.9-r0; preserve immutable Node bases and runtime contracts. Local inventory/runtime contract tests pass. Publication and deployment remain held. --- docs/reference/container-supply-chain.md | 21 ++++++++++++++++++--- governance/container-images.json | 4 ++-- infra/chaintracks-server/Dockerfile | 4 ++-- infra/message-box-server/Dockerfile | 2 +- infra/overlay-server/Dockerfile | 4 ++-- infra/uhrp-server-basic/Dockerfile | 4 ++-- infra/uhrp-server-cloud-bucket/Dockerfile | 4 ++-- infra/wab/Dockerfile | 4 ++-- infra/wallet-infra/Dockerfile | 4 ++-- 9 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/reference/container-supply-chain.md b/docs/reference/container-supply-chain.md index ea2b62d7a..26b06c8d3 100644 --- a/docs/reference/container-supply-chain.md +++ b/docs/reference/container-supply-chain.md @@ -2,9 +2,9 @@ id: container-supply-chain title: 'Container Supply Chain' kind: reference -version: '1.2.1' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '1.2.2' +last_updated: '2026-09-30' +last_verified: '2026-09-30' review_cadence_days: 30 status: stable tags: [reference, infrastructure, containers, security, releases] @@ -61,6 +61,21 @@ Repository health requires every final runtime stage to install every active pin. A base refresh that incorporates the fix must remove the obsolete runtime pins and their Dockerfile installs together. +The September 30, 2026 source refresh advances `libcrypto3` and `libssl3` to +`3.5.9-r0` across all seven images. Alpine replaced the previously pinned +`3.5.8-r0` packages in its rolling stable repository, causing exact-version +installs to fail. The reviewed [OpenSSL 3.5.9 security release](https://openssl-library.org/news/openssl-3.5-notes/) +retains the earlier CVE-2026-14456 fix and adds the September security fixes, +including CVE-2026-84782. The official [Alpine v3.24 x86_64 package index](https://dl-cdn.alpinelinux.org/alpine/v3.24/main/x86_64/APKINDEX.tar.gz) +retrieved at review names both packages at `3.5.9-r0` from aports commit +`29b9ec24b1b5b39aeef51fa2a044210e2ec5258e`; `libssl3` requires that exact +`libcrypto3` version. Both retain their `.so.3` ABI. This patch changes neither +the immutable Node base nor service APIs, configuration or persistence. +All seven Linux/amd64 build, scan and runtime gates must validate the source +candidate; this record does not claim a publication or deployment. A deployed +rollback uses a previously verified complete image digest; it must not restore +the vulnerable base libraries by removing the pins. + Package locks under `infra/**/package-lock.json` are committed release inputs. Release workflows never rewrite them. A stale or inconsistent lock therefore fails `npm ci` before publication instead of silently producing a different diff --git a/governance/container-images.json b/governance/container-images.json index 9c3f736c2..8ff7a048d 100644 --- a/governance/container-images.json +++ b/governance/container-images.json @@ -14,12 +14,12 @@ "runtimePackagePins": [ { "name": "libcrypto3", - "version": "3.5.8-r0", + "version": "3.5.9-r0", "reason": "CVE-2026-14456" }, { "name": "libssl3", - "version": "3.5.8-r0", + "version": "3.5.9-r0", "reason": "CVE-2026-14456" } ] diff --git a/infra/chaintracks-server/Dockerfile b/infra/chaintracks-server/Dockerfile index dda1a4dbb..23d9c6f0e 100644 --- a/infra/chaintracks-server/Dockerfile +++ b/infra/chaintracks-server/Dockerfile @@ -29,11 +29,11 @@ FROM node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbf WORKDIR /app # Install better-sqlite3 native build tools (see builder stage). The pinned -# base contains OpenSSL 3.5.7-r0, so install the reviewed 3.5.8-r0 fix for +# base contains OpenSSL 3.5.7-r0, so install the reviewed 3.5.9-r0 fix for # CVE-2026-14456 alongside the disposable toolchain. The toolchain is removed # after npm ci below while the fixed runtime libraries remain. RUN apk add --no-cache --virtual .build-deps g++ make python3 \ - && apk add --no-cache libcrypto3=3.5.8-r0 libssl3=3.5.8-r0 + && apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 # Copy package files COPY package*.json ./ diff --git a/infra/message-box-server/Dockerfile b/infra/message-box-server/Dockerfile index 8df74c96a..f7ecc5803 100644 --- a/infra/message-box-server/Dockerfile +++ b/infra/message-box-server/Dockerfile @@ -38,7 +38,7 @@ WORKDIR /app # fixed OpenSSL runtime packages after removing the disposable toolchain. COPY --chown=root:root --chmod=0444 package*.json ./ RUN apk add --no-cache --virtual .build-deps g++ make python3 \ - && apk add --no-cache libcrypto3=3.5.8-r0 libssl3=3.5.8-r0 \ + && apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 \ && npm ci --omit=dev --ignore-scripts \ && npm rebuild better-sqlite3 \ && apk del .build-deps \ diff --git a/infra/overlay-server/Dockerfile b/infra/overlay-server/Dockerfile index faf1e33fe..514466334 100644 --- a/infra/overlay-server/Dockerfile +++ b/infra/overlay-server/Dockerfile @@ -27,8 +27,8 @@ LABEL org.bsvblockchain.license.notice="/usr/share/licenses/ts-stack/THIRD_PARTY # The service starts directly with Node, so the runtime image does not retain # npm or its unrelated dependency graph. The pinned base contains OpenSSL -# 3.5.7-r0; retain the reviewed 3.5.8-r0 fix for CVE-2026-14456. -RUN apk add --no-cache libcrypto3=3.5.8-r0 libssl3=3.5.8-r0 \ +# 3.5.7-r0; retain the reviewed 3.5.9-r0 fix for CVE-2026-14456. +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 \ && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx USER node diff --git a/infra/uhrp-server-basic/Dockerfile b/infra/uhrp-server-basic/Dockerfile index 134b75be9..62ff74ff4 100644 --- a/infra/uhrp-server-basic/Dockerfile +++ b/infra/uhrp-server-basic/Dockerfile @@ -19,9 +19,9 @@ FROM node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbf # Strip npm from the runtime image: the app runs via `node` (see CMD) and # node:24-alpine's bundled npm ships its own HIGH-CVE deps (picomatch/undici) # that we don't need at runtime. Alpine 3.24.1's pinned Node image contains -# OpenSSL 3.5.7-r0, so install the fixed 3.5.8-r0 runtime packages for +# OpenSSL 3.5.7-r0, so install the fixed 3.5.9-r0 runtime packages for # CVE-2026-14456 in the same layer. -RUN apk add --no-cache libcrypto3=3.5.8-r0 libssl3=3.5.8-r0 \ +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 \ && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx WORKDIR /app diff --git a/infra/uhrp-server-cloud-bucket/Dockerfile b/infra/uhrp-server-cloud-bucket/Dockerfile index c716f828d..48db35c04 100644 --- a/infra/uhrp-server-cloud-bucket/Dockerfile +++ b/infra/uhrp-server-cloud-bucket/Dockerfile @@ -26,9 +26,9 @@ FROM node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbf # Strip npm from the runtime image: the app runs via `node` (see CMD) and # node:24-alpine's bundled npm ships its own HIGH-CVE deps (picomatch/undici) # that we don't need at runtime. Alpine 3.24.1's pinned Node image contains -# OpenSSL 3.5.7-r0, so install the fixed 3.5.8-r0 runtime packages for +# OpenSSL 3.5.7-r0, so install the fixed 3.5.9-r0 runtime packages for # CVE-2026-14456 in the same layer. -RUN apk add --no-cache libcrypto3=3.5.8-r0 libssl3=3.5.8-r0 \ +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 \ && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx ARG APP_COMMIT diff --git a/infra/wab/Dockerfile b/infra/wab/Dockerfile index d91711358..802c23d5b 100644 --- a/infra/wab/Dockerfile +++ b/infra/wab/Dockerfile @@ -30,8 +30,8 @@ FROM node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbf # Strip npm from the runtime image: the app runs via `node` (see CMD) and # node:24-alpine's bundled npm ships its own HIGH-CVE deps (picomatch/undici) # that we don't need at runtime. The pinned base contains OpenSSL 3.5.7-r0; -# retain the reviewed 3.5.8-r0 fix for CVE-2026-14456. -RUN apk add --no-cache libcrypto3=3.5.8-r0 libssl3=3.5.8-r0 \ +# retain the reviewed 3.5.9-r0 fix for CVE-2026-14456. +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 \ && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx WORKDIR /app diff --git a/infra/wallet-infra/Dockerfile b/infra/wallet-infra/Dockerfile index d581df200..d934e5bc4 100644 --- a/infra/wallet-infra/Dockerfile +++ b/infra/wallet-infra/Dockerfile @@ -16,9 +16,9 @@ RUN npm run build \ FROM node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd -# The pinned base contains OpenSSL 3.5.7-r0. Install the reviewed 3.5.8-r0 +# The pinned base contains OpenSSL 3.5.7-r0. Install the reviewed 3.5.9-r0 # runtime fix for CVE-2026-14456 together with nginx. -RUN apk add --no-cache libcrypto3=3.5.8-r0 libssl3=3.5.8-r0 nginx \ +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 nginx \ && chown -R node:node /var/lib/nginx COPY ./nginx.conf /etc/nginx/nginx.conf From 6d1493ff22b9d5c6a72054554acdb2514a704f35 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 02:22:03 -0700 Subject: [PATCH 041/127] feat(wallet-toolbox): persist bounded local snapshot sync --- docs/guides/wallet-sync-reliability.md | 97 +- docs/reference/package-api-migrations.md | 84 +- governance/mutation-testing/policy.json | 10 + governance/mutation-testing/targets.mjs | 103 ++ governance/package-release-notes.json | 12 +- governance/test-quality/policy.json | 13 + packages/wallet/wallet-toolbox/CHANGELOG.md | 16 +- packages/wallet/wallet-toolbox/README.md | 36 +- .../wallet/wallet-toolbox/client/README.md | 8 + .../wallet/wallet-toolbox/mobile/README.md | 8 + packages/wallet/wallet-toolbox/package.json | 2 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 155 +- .../src/storage/StorageProvider.ts | 8 +- .../src/storage/WalletStorageManager.ts | 237 ++- .../wallet-toolbox/src/storage/index.all.ts | 9 + .../src/storage/index.client.ts | 9 + .../src/storage/index.mobile.ts | 9 + .../storage/methods/validateSyncProof.test.ts | 19 + .../src/storage/methods/validateSyncProof.ts | 7 +- .../remoting/__test/StorageServerRpc.test.ts | 1 + .../src/storage/schema/KnexMigrations.ts | 9 + .../schema/entities/EntityProvenTxReq.ts | 17 +- .../schema/entities/EntitySyncState.ts | 71 +- .../schema/entities/mergeSyncChunkEntities.ts | 74 + .../schema/snapshotSyncMigration.test.ts | 112 ++ .../storage/schema/snapshotSyncMigration.ts | 92 ++ .../ConcurrentSnapshotSyncSource.test.ts | 246 +++ .../snapshot/KnexSnapshotSyncDestination.ts | 314 ++++ .../snapshot/KnexWalletReadSnapshot.ts | 3 +- .../storage/snapshot/RetainedReadSnapshot.ts | 5 +- .../snapshot/SnapshotResourceLimitError.ts | 4 + .../snapshot/SnapshotSync.integration.test.ts | 1387 +++++++++++++++++ .../snapshot/SnapshotSync.property.test.ts | 148 ++ .../src/storage/snapshot/SnapshotSync.ts | 70 + .../storage/snapshot/SnapshotSyncRows.test.ts | 261 ++++ .../src/storage/snapshot/SnapshotSyncRows.ts | 244 +++ .../snapshot/SnapshotSyncSession.test.ts | 327 ++++ .../StorageKnex.retainedSnapshot.test.ts | 1 + .../snapshot/runSnapshotSyncSession.ts | 189 +++ .../src/storage/sync/syncSession.ts | 7 + scripts/test-governance.test.mjs | 4 +- specs/wallet/sync-portability-program.md | 26 +- 42 files changed, 4298 insertions(+), 156 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/entities/mergeSyncChunkEntities.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotResourceLimitError.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 8c64c65cd..bdfb25e5a 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -49,7 +49,8 @@ commit acknowledgement. It does not abort a database transaction after its outcome becomes uncertain or undo acknowledged data. Invoke the API again to resume; do not use UI counters as a checkpoint. A lost acknowledgement rejects without automatically replaying a write, and the next session loads the durable -destination checkpoint. +destination state. The retained local SQL path distinguishes resuming a still-live +view from restarting a replacement view, as described below. Only one page is in flight. The new API defaults to 1,000 rows and 262,144 rough encoded bytes (256 KiB). Options must be positive safe integers, no greater than @@ -75,7 +76,8 @@ Switching/destroying the destination fences late replies before mutation. The page commit rechecks the durable checkpoint and any proof rows inspected during preparation, preventing concurrent sessions or monitor repairs from overwriting newer state. Input pages and checkpoint values are detached before asynchronous -validation. No new persisted schema or archive envelope is introduced. +validation. Legacy checkpoints retain their schema; the durable local SQL path +below adds auxiliary persistence. No archive envelope changes. The queue permits at most eight concurrent readers on providers advertising safe reads. Writers remain exclusive. Foreground work has priority, with bounded @@ -114,7 +116,7 @@ was repaired. ## Timestamp boundaries and snapshot scope -This API preserves BRC-40's inclusive `since` boundary and existing entity order, +The legacy timestamp/offset path preserves BRC-40's inclusive `since` boundary and existing entity order, ID mapping, tombstones and merge rules. Records arriving later with the same last timestamp must remain discoverable. Consequently, a large imported batch sharing one timestamp can require a complete boundary reread even when it has @@ -157,9 +159,9 @@ The lifetime limits retention time, not the bytes accumulated in database WAL or undo history while other writers continue; database storage limits remain separate. The primitive itself does not provide a durable cursor, -remote handle, concurrent IndexedDB snapshot, or streaming archive. Ordinary -push/backup loops still require the scheduling and checkpoint work below. No -persisted schema, legacy index order or wire encoding changes in this checkpoint. +remote handle, concurrent IndexedDB snapshot, or streaming archive. The local +sync integration below adds destination persistence and scheduling separately; +the retained-view primitive itself does not change legacy index order or wire bytes. ### Profile-bound local SQL pages @@ -216,10 +218,85 @@ not the canonical BRC-38 array order. No OFFSET, full-table count, new index or persistence migration is introduced. Existing legacy sync checkpoints and query plans are unchanged. SQLite query-plan tests verify range seeks for numeric and composite keys. Identity/update-key indexing, commit-order incremental high-water -positions, remote handles, IDB retention, streaming and resumable push/backup -remain part of the active program; this page API does not enable them by itself. - -## Next-stage design checkpoint (not implemented) +positions, remote handles, IDB retention and streaming remain part of the active +program. The local sync integration below consumes these pages. + +## Durable local SQL sync and ordinary backup + +With the version-one migration applied, supported local SQL providers use these +pages for ordinary `syncFromReader`, `syncToWriter` and `updateBackups` calls. +`syncFromReaderResumable` and the additive `syncToWriterResumable(auth, writer, +options)` expose cancellation and page progress. Source reading and proof +preparation run outside manager ownership; only destination admission and each +atomic page commit enter the fair background queue. Foreground reads and writes +can proceed between those commits. Existing `progLog` callbacks receive each +committed page and the completion summary, including during serialized fallback; +their returned text remains part of the ordinary result log. Primary reconciliation still uses its existing +exclusive path and remains required work in the full program. + +The source gets a dedicated one-connection pool, preserving the original pool's +foreground capacity and telemetry configuration. SQLite requires a file-backed +WAL database. MySQL requires a static connection configuration with a database; +dynamic connection factories, externally shared pools and unsupported providers +retain the existing serialized path. Each provider admits one opening/live sync +source until physical cleanup completes. The default five-minute retention limit +includes opening; `snapshotLifetimeMs` accepts 1–3,600,000 milliseconds. Driver +query deadlines and WAL/undo disk limits remain separate. + +Migration `2026-09-30-001 add durable snapshot sync` adds three auxiliary tables: +`snapshot_sync_sessions`, `snapshot_sync_ids` and +`snapshot_sync_primary_epochs`. A database trigger increments the primary epoch +on every primary change, including an independent writer changing away and back. +It does not change standard-table key order or legacy `sync_states.syncMap` JSON. +SQLite commits the migration atomically. MySQL's idempotent table, foreign-key +and trigger installation can resume after partially committed DDL. Apply +migrations using the normal provider migration entry point before enabling the +capability; merely opening storage does not upgrade its schema. MySQL migration +credentials need permission to create the auxiliary tables, foreign keys and +trigger; the ordinary runtime connection does not install them implicitly. + +A session binds the wallet identity, source/destination storage identities, +network, source view, selected primary and its epoch. Rows, normalized ID mappings +and the destination cursor commit together. Source user metadata joins the first +page commit; cancellation before that commit leaves the selected primary intact. +A prepared page is single-use and stale checkpoints reject. If an acknowledgement +is lost while the same source view remains alive, read the destination checkpoint +and resume it. When the source view is lost, open a new view and restart traversal +from the first table; committed mappings and entity merge semantics prevent +repeated inserts. Manager calls own and close their source view, so calling a +manager sync method again starts a replacement view. Advanced callers retaining +the same local capability handle can resume its acknowledged cursor. No source +cursor survives source process loss. Returned acknowledgements must match the +session bindings, next sequence, table position, completion flag and page cursor; +a mismatch rejects before another read or progress count is accepted. + +One packed page is in flight. The resumable APIs default to at most 1,000 rows +and 262,144 charged bytes; configurable ceilings are 1,000 rows and 10,000,000 +bytes. Ordinary push, pull and backup retain their 1,000-row/10,000,000-byte +ceilings; the adaptive controller may request smaller pages. Per-page mapping +work is limited to 4,096 distinct IDs, queried and persisted in batches of 128. +An oversized row, reference limit or retention expiry closes the source and uses +the established serialized fallback, preserving progress counts. Malformed rows, +profile/network mismatches, stale sessions and I/O failures reject. Fallback can +hold manager ownership for the remaining copy; bounded large-value streaming is +still required. The result/progress `mode` reports the selected behavior. + +`snapshotCheckpoint` is separate from the legacy `checkpoint` field. It records +this destination's durable position, not transferable authorization. The local +`getSnapshotSync()` capability is excluded from RPC, and its types do not imply +an IndexedDB, remote or native adapter. The twelve replica tables preserve the +existing merge policy; original source sync-state history remains an archive +concern. BRC-38/39 bytes are unchanged. + +For forward rollback, construct `StorageKnex` with `snapshotSync: false` on both +sides and retain the additive schema. Existing sync APIs then use their established +paths. Stop all new sessions before any binary downgrade; older migration code +may refuse a database containing newer journal entries, so disabling this feature +on a current binary is the supported operational rollback. Do not drop auxiliary +mapping/session state while work is active. No package is published or provider +upgraded by this source change. + +## Next-stage remote design checkpoint (not implemented) A future source-snapshot capability should negotiate a versioned contract separately from ordinary sync and bounded transfer. A snapshot must bind the diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index b5fb7099d..4b449ab33 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC and do not yet change ordinary backup scheduling. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -537,8 +537,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -549,8 +549,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index d22d339ce..109e6c06d 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -67,6 +67,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-snapshot-sync", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts", + "risk": "critical", + "boundary": "Wallet durable profile-bound snapshot merges, normalized ID mappings, acknowledged cursors and primary-generation fencing", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "overlay-linkage", "manifest": "packages/overlays/topics/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 63e71caef..3cb8b0f05 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -262,6 +262,109 @@ export function buildMutationTargets(repositoryRoot) { } }) }, + 'wallet-snapshot-sync': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts', + mutate: [ + 'src/storage/snapshot/SnapshotSync.ts', + 'src/storage/snapshot/SnapshotSyncRows.ts', + 'src/storage/snapshot/KnexSnapshotSyncDestination.ts', + 'src/storage/snapshot/runSnapshotSyncSession.ts', + 'src/storage/schema/snapshotSyncMigration.ts', + 'src/storage/schema/entities/mergeSyncChunkEntities.ts', + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/methods/validateSyncProof.ts', + 'if (!(candidate.rawTx instanceof Uint8Array', + ' try {' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'this.snapshotSyncEnabled = options.snapshotSync', + 'this.preparedBeefPolicy =' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/schema/entities/EntityProvenTxReq.ts', + 'override async mergeExisting(', + 'export interface ProvenTxReqHistorySummaryApi' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override getSnapshotSync():', + 'protected override supportsNoSendExpiryPersistence()' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'private async runSnapshotCopy(', + 'async syncFromReader(' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async syncFromReader(', + ' let inserts = 0' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async syncFromReaderResumable(', + ' const generation =' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async syncToWriterResumable(', + 'async syncToWriter(' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async syncToWriter(', + ' let inserts = 0' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async updateBackups(', + 'async setActive(' + ) + ], + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/snapshot/SnapshotSync*.test.ts', + '/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts', + '/src/storage/schema/snapshotSyncMigration.test.ts', + '/src/storage/methods/validateSyncProof.test.ts', + '/src/storage/sync/syncFailure.test.ts', + '/src/storage/sync/syncSession.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + }, 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', manifest: 'packages/overlays/topics/package.json', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 0e8ac83b7..1e6fb63ae 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC and do not yet change ordinary backup scheduling. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. No persisted-schema migration is required. Inclusive timestamp boundaries may reread equal-time rows; this API is not a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. No schema, index or wire migration is introduced. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program." + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters.", + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. No schema migration is required. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. Existing schema/indexes and legacy OFFSET/checkpoint behavior remain unchanged. This does not enable resumable push/backup or complete the full portability program." + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters.", + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." }, { "name": "create-bsv-app", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 8268894e5..e4a1ec1c4 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -109,6 +109,19 @@ "Pinned keyset traversal returns every original owned row and tombstone exactly once under independent writes, within both page limits; cursor retries repeat the same page." ] }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet durable profile-bound snapshot merges, normalized ID mappings, acknowledged cursors and primary-generation fencing", + "target": "Randomized bounded SQL page, stale preparation, acknowledgement loss and source-view replacement schedules with an independent profile/content oracle", + "invariants": [ + "Only the acknowledged destination cursor advances after each atomic page commit.", + "Concurrent prepared pages cannot overwrite a newer checkpoint, and replacing a source view fences its old pages.", + "Incoming IDs and parent references are mapped within one wallet profile without duplicating logical records.", + "A restart explicitly begins the replacement view at its first table while preserving existing destination data and legacy checkpoints." + ] + }, { "path": "packages/overlays/topics/src/mandala/__tests/types.property.test.ts", "manifest": "packages/overlays/topics/package.json", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index fdfb9803b..df055c060 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,12 +6,23 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Integrate coherent SQL pages into ordinary local push, pull and backup, with + a dedicated source reader and short, fair destination commits. Add resumable + push progress/cancellation. Commit rows, normalized ID maps and durable cursors + together; reject stale sessions and independent primary ABA transitions. + Add the version-one auxiliary schema migration and primary-epoch trigger. + Existing legacy checkpoint JSON is preserved. Unsupported configurations and + explicit row/reference/retention limits retain serialized fallback; genuine + validation and I/O errors reject. `snapshotSync: false` is the supported forward + rollback with schema retained. Primary reconciliation, remote/IDB retained + views, streaming and staged restore remain required work. + - Add local profile-bound SQL keyset pages over retained views. All thirteen standard tables preserve tombstones and original source records; binary values stay packed. SQL preflights bounded keys and payload sizes before fetching a page. Oversized individual rows explicitly refuse pending large-value streaming. Cursors belong only to their live view and table; expiry/process loss requires - restart. Existing indexes, schemas, OFFSET checkpoints and RPC remain unchanged. + restart. The page primitive preserves existing indexes, OFFSET checkpoints and RPC. This is a prerequisite for the active streaming/resumable-backup program. - Add local retained SQLite/MySQL views with explicit lifetime, cancellation and @@ -22,7 +33,8 @@ attention to changes that materially alter behavior or extend functionality. before its opening consumer resumes; callers still receive the original errors. Driver/query deadlines remain separate. Each view occupies a pool connection; IndexedDB and RPC explicitly remain unsupported for retention. This is a paging - prerequisite, without changing ordinary backup locks, schemas or wire formats. + prerequisite; the separate sync integration above changes scheduling and adds + auxiliary persistence without changing wire formats. - Capture BRC-38 source metadata and every table from one provider read view. Custom providers opt in with `supportsReadSnapshot` and `readSnapshot`. The diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index e5f8271b0..21811c7d6 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -426,15 +426,16 @@ session before its next write. Concurrent copies of the same source cannot both commit the same checkpoint. Progress observers receive independent checkpoint copies and run outside page ownership in paged mode. -Missing capabilities, remote destinations, self-copies, existing whole-copy -methods and primary reconciliation retain exclusive execution. A failed -capability lookup falls back to serialization. Page ceilings are rough encoded +Missing capabilities, remote destinations, self-copies and primary reconciliation +retain exclusive execution. Supported ordinary local SQL copies use the durable +snapshot path described below. The legacy atomic-page capability probe retains +its serialized fallback; snapshot validation and I/O failures reject. Page ceilings are rough encoded size hints; the existing negotiated per-record transfer bounds still apply to large records. One page is in flight, and progress does not accumulate wallet -records or per-record logs. Source pagination retains the existing eventual -replication contract: this API is **not a coherent source snapshot**. Source -snapshot handles and streaming portable archives require their separate -consistency and format contracts. +records or per-record logs. Legacy source pagination retains the existing eventual +replication contract. Supported local SQL sources use one coherent retained view; +the destination remains a merged replica. Remote snapshot handles and streaming +portable archives require their separate consistency and format contracts. The existing timestamp boundary is inclusive: an unchanged copy can reread rows sharing the final timestamp, including an entire same-timestamp import. Those rows are not rewritten. This protects late same-time arrivals; a coherent source @@ -1230,7 +1231,24 @@ all thirteen standard tables share one retained read view. `readPage(table, cursor, { maxRows, maxBytes })` uses stable storage keys and checks payload size before loading rows; binary columns remain `Uint8Array`. Always close the view. Cursors expire with the view and cannot resume after process loss. Oversized rows -explicitly refuse pending large-value streaming; no schema/index or legacy sync -change is introduced. IndexedDB and RPC do not expose this capability. See the +explicitly refuse pending large-value streaming. IndexedDB and RPC do not expose +this capability; the page primitive preserves existing indexes and legacy cursors. See the [page contract and limits](../../../docs/guides/wallet-sync-reliability.md#profile-bound-local-sql-pages). The full #544 program remains incomplete. + +### Durable local SQL backup integration (2.15 candidate) + +Supported ordinary push, pull and backup calls now consume those pages outside +manager ownership and commit each destination page, ID mapping and cursor +atomically. `syncToWriterResumable` joins `syncFromReaderResumable` for progress and +cancellation. A dedicated reader preserves foreground pool capacity. SQLite +requires file-backed WAL; MySQL requires a static database connection. Unsupported +providers, oversized rows and retention limits use the serialized fallback. + +Apply migration `2026-09-30-001 add durable snapshot sync` through `migrate()`. +It adds auxiliary session/mapping/primary-epoch tables and a primary-change trigger; +legacy checkpoint JSON and standard-table indexes are preserved. Use +`snapshotSync: false` for forward rollback on a current binary while keeping the +schema. See [durability, limits and downgrade guidance](../../../docs/guides/wallet-sync-reliability.md#durable-local-sql-sync-and-ordinary-backup). +Primary reconciliation, remote/IDB retained views, large-value streaming and +staged archive restore remain part of the incomplete #544 program. diff --git a/packages/wallet/wallet-toolbox/client/README.md b/packages/wallet/wallet-toolbox/client/README.md index 9af79c612..04f29e219 100644 --- a/packages/wallet/wallet-toolbox/client/README.md +++ b/packages/wallet/wallet-toolbox/client/README.md @@ -233,3 +233,11 @@ packed-row types. The base provider explicitly refuses unsupported local pages; only `StorageKnex` currently implements them. Type availability does not imply an IndexedDB, native mobile or remote snapshot implementation. Existing sync and archive APIs retain their behavior. See the [SQL paging contract](../../../../docs/guides/wallet-sync-reliability.md#profile-bound-local-sql-pages). + +The candidate also exports `snapshotSyncTables` and the version-one +`SnapshotSyncSource`, `SnapshotSyncCheckpoint`, `SnapshotSyncCommit`, +`SnapshotSyncStorage` and `SnapshotSyncTable` types. `syncToWriterResumable` adds +push progress/cancellation with an explicit exclusive fallback. These exports +support adapter integration; they do not enable SQL retention or the auxiliary +SQL migration in this browser/mobile entry point. Current IndexedDB and remote +paths retain their documented behavior. See the [local SQL integration and remaining limits](../../../../docs/guides/wallet-sync-reliability.md#durable-local-sql-sync-and-ordinary-backup). diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 0b958799b..d4c74fa5d 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -269,3 +269,11 @@ packed-row types. The base provider explicitly refuses unsupported local pages; only `StorageKnex` currently implements them. Type availability does not imply an IndexedDB, native mobile or remote snapshot implementation. Existing sync and archive APIs retain their behavior. See the [SQL paging contract](../../../../docs/guides/wallet-sync-reliability.md#profile-bound-local-sql-pages). + +The candidate also exports `snapshotSyncTables` and the version-one +`SnapshotSyncSource`, `SnapshotSyncCheckpoint`, `SnapshotSyncCommit`, +`SnapshotSyncStorage` and `SnapshotSyncTable` types. `syncToWriterResumable` adds +push progress/cancellation with an explicit exclusive fallback. These exports +support adapter integration; they do not enable SQL retention or the auxiliary +SQL migration in this browser/mobile entry point. Current IndexedDB and remote +paths retain their documented behavior. See the [local SQL integration and remaining limits](../../../../docs/guides/wallet-sync-reliability.md#durable-local-sql-sync-and-ordinary-backup). diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index eea1569ba..177388d7d 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 84d4928f4..b40f3f582 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,3 +1,6 @@ +import { SnapshotResourceLimitError } from './snapshot/SnapshotResourceLimitError' +import type { SnapshotSyncStorage } from './snapshot/SnapshotSync' +import { KnexSnapshotSyncDestination } from './snapshot/KnexSnapshotSyncDestination' import type { WalletReadSnapshot, WalletReadSnapshotOptions } from './snapshot/WalletReadSnapshot' import { openKnexWalletReadSnapshot } from './snapshot/KnexWalletReadSnapshot' import { @@ -33,7 +36,7 @@ import { import { TableActionBatch, TableActionBatchBlob, TableActionBatchOutput } from './schema/tables/TableActionBatch' import { TablePreparedBeef } from './schema/tables/TablePreparedBeef.interfaces' import { KnexMigrations } from './schema/KnexMigrations' -import { Knex } from 'knex' +import { knex as createKnex, Knex } from 'knex' import { AdminStatsResult, StorageProvider, StorageProviderOptions } from './StorageProvider' import { purgeData } from './methods/purgeData' import { listActions } from './methods/listActionsKnex' @@ -101,6 +104,8 @@ export interface StorageKnexOptions extends StorageProviderOptions { knex: Knex /** Optional prepared-BEEF (COOK) rollout controls. Disabled by default. */ preparedBeef?: PreparedBeefOptions + /** Default true. False keeps legacy sync scheduling while retaining the additive schema for forward rollback. */ + snapshotSync?: boolean } // Keep bulk statements below conservative SQLite/MySQL bind-parameter @@ -120,6 +125,11 @@ interface PreparedBeefMetadata { export class StorageKnex extends StorageProvider implements WalletStorageProvider { knex: Knex + private readonly snapshotSyncEnabled: boolean + private readonly snapshotSyncTelemetry: StorageKnexOptions['telemetry'] + private snapshotSyncSource?: StorageKnex + private snapshotSyncOpening?: Promise + private snapshotSyncBusy = false private retainedReadSnapshot?: RetainedReadSnapshotLifetime private retainedReadSnapshotsStopped = false readonly preparedBeefPolicy: PreparedBeefPolicy @@ -152,6 +162,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide super(options) if (options.knex == null) throw new WERR_INVALID_PARAMETER('options.knex', 'valid') this.knex = options.knex + this.snapshotSyncEnabled = options.snapshotSync ?? true + this.snapshotSyncTelemetry = options.telemetry + if (typeof this.snapshotSyncEnabled !== 'boolean') throw new WERR_INVALID_PARAMETER('snapshotSync', 'a boolean') this.preparedBeefPolicy = validatePreparedBeefPolicy(options.preparedBeef) this.preparedBeefCoordinator = new PreparedBeefCoordinator(this) if (this.telemetry.enabled) { @@ -238,6 +251,8 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide // Fence admission before any asynchronous cleanup can yield. A view whose // close releases the capacity slot must not permit reopening during destroy. this.retainedReadSnapshotsStopped = true + await this.snapshotSyncOpening?.catch(() => undefined) + await this.snapshotSyncSource?.destroy() await this.retainedReadSnapshot?.close() } @@ -282,6 +297,142 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } } + override getSnapshotSync(): SnapshotSyncStorage | undefined { + if (!this.snapshotSyncEnabled || !this.supportsWalletReadSnapshot()) return undefined + const destination = new KnexSnapshotSyncDestination(this, chunk => this.prepareSyncProofs(chunk)) + return { + supportsDestination: () => destination.supportsDestination(), + openSource: (identityKey, options) => this.openConcurrentSyncSource(identityKey, options), + begin: (source, activeStorage) => destination.begin(source, activeStorage), + checkpoint: (identityKey, sourceIdentity) => destination.checkpoint(identityKey, sourceIdentity), + prepare: (checkpoint, page) => destination.prepare(checkpoint, page) + } + } + + private async openConcurrentSyncSource( + identityKey: string, + options: WalletReadSnapshotOptions = {} + ): Promise { + if (this.retainedReadSnapshotsStopped) + throw new WERR_INVALID_OPERATION('Snapshot sync is unavailable after destruction begins') + if (this.snapshotSyncBusy) + throw new WERR_INVALID_OPERATION('This provider already has a snapshot sync source opening or active') + const lifetimeMs = options.lifetimeMs ?? 300000 + if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs < 1 || lifetimeMs > 3600000) { + throw new WERR_INVALID_PARAMETER('lifetimeMs', 'an integer from 1 to 3600000') + } + const deadline = { expiresAt: Date.now() + lifetimeMs, startedAt: performance.now(), lifetimeMs } + this.snapshotSyncBusy = true + const opening: Promise = this.createConcurrentSyncSource( + identityKey, + { ...options }, + deadline + ) + .then( + view => { + if (view === undefined) this.snapshotSyncBusy = false + return view + }, + error => { + this.snapshotSyncBusy = false + throw error + } + ) + .finally(() => { + if (this.snapshotSyncOpening === opening) this.snapshotSyncOpening = undefined + }) + this.snapshotSyncOpening = opening + return await opening + } + + private async createConcurrentSyncSource( + identityKey: string, + options: WalletReadSnapshotOptions, + deadline: { expiresAt: number; startedAt: number; lifetimeMs: number } + ): Promise { + if (options.signal?.aborted === true) throw new WERR_INVALID_OPERATION('Snapshot sync source was cancelled') + const config = this.knex.client.config as Knex.Config + const connection = config.connection + // A function or external pool cannot promise an independent connection. + // In-memory SQLite cannot share a coherent WAL view with a separate pool. + if ( + connection === null || + typeof connection !== 'object' || + ('connectionPool' in config && config.connectionPool != null) + ) + return undefined + if (this.databaseSystem() === 'sqlite') { + const filename = (connection as Knex.Sqlite3ConnectionConfig).filename + if ( + typeof filename !== 'string' || + filename.length === 0 || + filename === ':memory:' || + filename.startsWith('file:') + ) + return undefined + const modes: Array<{ journal_mode: string }> = await this.knex.raw('PRAGMA journal_mode') + if (modes[0]?.journal_mode.toLowerCase() !== 'wal') return undefined + } else if ( + this.databaseSystem() !== 'mysql' || + !('database' in connection) || + typeof connection.database !== 'string' + ) + return undefined + if (this.retainedReadSnapshotsStopped) + throw new WERR_INVALID_OPERATION('Snapshot sync is unavailable after destruction begins') + const remaining = Math.floor( + Math.min(deadline.expiresAt - Date.now(), deadline.lifetimeMs - (performance.now() - deadline.startedAt)) + ) + if (remaining < 1) throw new SnapshotResourceLimitError('Snapshot sync source expired during connection setup') + const reader = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions(this.chain), + snapshotSync: false, + telemetry: this.snapshotSyncTelemetry, + knex: createKnex({ + ...config, // Knex deliberately makes passwords non-enumerable. Preserve descriptors + // in memory instead of dropping credentials or making them log-visible. + connection: Object.create(Object.getPrototypeOf(connection), Object.getOwnPropertyDescriptors(connection)), + pool: { ...config.pool, min: 0, max: 1 } + }) + }) + this.snapshotSyncSource = reader + try { + const view = await reader.openWalletReadSnapshot(identityKey, { ...options, lifetimeMs: remaining }) + if (this.retainedReadSnapshotsStopped) { + await view.close() + throw new WERR_INVALID_OPERATION('Snapshot sync is unavailable after destruction begins') + } + const closed = view.closed.finally(() => this.releaseConcurrentSource(reader)) + void closed.catch(() => undefined) + return { + ...view, + get isOpen() { + return view.isOpen + }, + closed, + close: async () => { + await view.close().catch(() => undefined) + await closed + } + } + } catch (error) { + await this.releaseConcurrentSource(reader) + throw error + } + } + + private async releaseConcurrentSource(reader: StorageKnex): Promise { + try { + await reader.destroy() + } catch (error) { + // Failed physical cleanup must never reopen capacity for another view. + this.retainedReadSnapshotsStopped = true + throw error + } + if (this.snapshotSyncSource === reader) this.snapshotSyncSource = undefined + this.snapshotSyncBusy = false + } + protected override supportsNoSendExpiryPersistence(): boolean { return true } @@ -2342,7 +2493,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide void this.select(1).from('action_batch_outputs as abo').whereRaw('abo.outputId = o.outputId') }) .whereIn('t.status', status) - .select('o.*') + .select('o.*') .forUpdate() let output: TableOutput | undefined diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index 4dbeeb08f..dc0a5feb9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -1,3 +1,4 @@ +import type { SnapshotSyncStorage } from './snapshot/SnapshotSync' import type { WalletReadSnapshot, WalletReadSnapshotOptions } from './snapshot/WalletReadSnapshot' import type { RetainedReadSnapshot, RetainedReadSnapshotOptions } from './snapshot/RetainedReadSnapshot' import { runInSeries } from '../utility/runInSeries' @@ -554,6 +555,11 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal return Promise.reject(new WERR_NOT_IMPLEMENTED('Wallet read snapshot pages are not supported by this provider')) } + /** Local versioned snapshot merge capability; older providers keep serialized sync. */ + getSnapshotSync(): SnapshotSyncStorage | undefined { + return undefined + } + protected supportsActionBatchPersistence(): boolean { return false } @@ -1446,7 +1452,7 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal }) } - private async prepareSyncProofs(chunk: SyncChunk): Promise> { + protected async prepareSyncProofs(chunk: SyncChunk): Promise> { const expected = new Map() // Canonicalize before text-key lookup in every sync mode. Direct // backup/conflict sync retains its established trust for new proof rows, diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index a1dd31593..d1ba86d8b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -1,3 +1,7 @@ +import { SnapshotResourceLimitError } from './snapshot/SnapshotResourceLimitError' +import { runSnapshotSyncSession } from './snapshot/runSnapshotSyncSession' +import type { SnapshotSyncStorage } from './snapshot/SnapshotSync' +import type { WalletReadSnapshot } from './snapshot/WalletReadSnapshot' import { runInSeries } from '../utility/runInSeries' import { type ValidCreateActionArgs, @@ -11,7 +15,12 @@ import { } from '@bsv/sdk/wallet/validationHelpers' import { SyncPageBudget } from './sync/SyncPageBudget' import { StorageAccessQueue } from './sync/StorageAccessQueue' -import { runPullSession, type SyncSessionOptions, type SyncSessionResult } from './sync/syncSession' +import { + runPullSession, + type SyncSessionOptions, + type SyncSessionProgress, + type SyncSessionResult +} from './sync/syncSession' import { validateSyncCheckpoint } from './sync/syncCheckpoint' import { assertSyncNetwork, assertSyncProgress, throwSyncResultError } from './sync/syncFailure' import { @@ -771,6 +780,145 @@ export class WalletStorageManager implements sdk.WalletStorage { return ss.makeRequestSyncChunkArgs(auth.identityKey, toStorageIdentityKey) } + private async runSnapshotCopy( + view: WalletReadSnapshot, + destination: SnapshotSyncStorage, + options: SyncSessionOptions, + generation: number, + active: sdk.WalletStorageProvider + ): Promise { + if (view.user.identityKey !== this._authId.identityKey) throw new WERR_UNAUTHORIZED() + return await runSnapshotSyncSession( + { + view, + destination, + activeStorage: this.getActiveUser().activeStorage, + commit: operation => + this.withAccess( + () => { + if (generation !== this.generation || active !== this.getActive()) { + throw new WERR_INVALID_OPERATION( + 'Snapshot sync primary generation changed; resume on the selected storage' + ) + } + return operation() + }, + false, + true + ) + }, + options + ) + } + + private committedPageLog(progress: SyncSessionProgress, logger: (message: string) => string): string { + if (progress.state !== 'committed') return '' + return logger( + `chunk ${progress.pages - 1} committed: ${progress.inserts} total inserts, ${progress.updates} total updates\n` + ) + } + + private async runLegacySnapshotFallback( + reader: sdk.WalletStorageSyncReader, + writer: sdk.WalletStorageProvider, + options: SyncSessionOptions, + selected: { generation: number; active: sdk.WalletStorageProvider } + ): Promise { + const readerSettings = await reader.makeAvailable() + const writerSettings = await writer.makeAvailable() + assertSyncNetwork(readerSettings, writerSettings) + return await this.runAsSync(() => { + if (selected.generation !== this.generation || selected.active !== this.getActive()) { + throw new WERR_INVALID_OPERATION('Snapshot sync primary generation changed; resume on the selected storage') + } + return runPullSession( + { + reader, + writer, + mode: 'exclusive', + atomicCheckpoint: false, + activeStorage: this.getActiveUser().activeStorage, + loadRequest: () => + this.loadSyncRequest(this._authId, writer, readerSettings, writerSettings.storageIdentityKey), + commit: operation => operation() + }, + options + ) + }) + } + + private async trySnapshotCopy( + reader: sdk.WalletStorageSyncReader, + writer: sdk.WalletStorageProvider, + options: SyncSessionOptions = {}, + selected = { generation: this.generation, active: this.getActive() } + ): Promise { + const source = reader instanceof StorageProvider ? reader.getSnapshotSync() : undefined + const destination = writer instanceof StorageProvider ? writer.getSnapshotSync() : undefined + if (source === undefined || destination === undefined || reader === writer) return undefined + if (!(await destination.supportsDestination())) return undefined + if (options.signal?.aborted === true) + return { status: 'cancelled', mode: 'paged', pages: 0, inserts: 0, updates: 0 } + let view: WalletReadSnapshot | undefined + let partial = { pages: 0, inserts: 0, updates: 0 } + try { + view = await source.openSource(this._authId.identityKey, { lifetimeMs: options.snapshotLifetimeMs }) + if (view === undefined) return undefined + let failed = false + try { + return await this.runSnapshotCopy( + view, + destination, + { + ...options, + onProgress: progress => { + partial = { pages: progress.pages, inserts: progress.inserts, updates: progress.updates } + options.onProgress?.(progress) + } + }, + selected.generation, + selected.active + ) + } catch (error) { + failed = true + throw error + } finally { + await view.close().catch(error => { + // A simultaneous expiry must not replace a malformed-row/session error. + // A physical cleanup failure, however, cannot be hidden by fallback. + if (!failed || !(error instanceof SnapshotResourceLimitError)) throw error + }) + } + } catch (error) { + // Existing ordinary backups must continue to accept large records and long + // copies. Only explicit resource limits select the established serialized + // fallback; corrupt rows, changed sessions and I/O failures still reject. + if (!(error instanceof SnapshotResourceLimitError)) throw error + } + const result = await this.runLegacySnapshotFallback( + reader, + writer, + { + ...options, + onProgress: progress => { + options.onProgress?.({ + ...progress, + pages: partial.pages + progress.pages, + inserts: partial.inserts + progress.inserts, + updates: partial.updates + progress.updates + }) + } + }, + selected + ) + return { + ...result, + pages: partial.pages + result.pages, + inserts: partial.inserts + result.inserts, + updates: partial.updates + result.updates + } + } + async syncFromReader( identityKey: string, reader: sdk.WalletStorageSyncReader, @@ -783,6 +931,17 @@ export class WalletStorageManager implements sdk.WalletStorage { if (activeSync != null) assertSyncNetwork(readerSettings, activeSync.getSettings()) const auth = await this.getAuth() + if (activeSync == null) { + const snapshot = await this.trySnapshotCopy(reader, this.getActive(), { maxRoughSize: 10000000 }) + if (snapshot !== undefined) + return { + inserts: snapshot.inserts, + updates: snapshot.updates, + log: + log + `syncFromReader ${snapshot.mode} complete: ${snapshot.inserts} inserts, ${snapshot.updates} updates\n` + } + } + let inserts = 0 let updates = 0 @@ -836,7 +995,8 @@ export class WalletStorageManager implements sdk.WalletStorage { * Resumable pull with cancellation and per-page progress. Local providers * advertising atomic checkpoints yield ownership during source I/O. Older * and remote destinations keep the safe exclusive path. This is an eventual - * replica merge, not a point-in-time source snapshot or primary activation. + * replica merge with a coherent source view when local SQL capabilities permit; + * it does not activate a primary or provide an archive snapshot of the destination. */ async syncFromReaderResumable( identityKey: string, @@ -850,6 +1010,8 @@ export class WalletStorageManager implements sdk.WalletStorage { const writer = this.getActive() const writerSettings = writer.getSettings() assertSyncNetwork(readerSettings, writerSettings) + const snapshot = await this.trySnapshotCopy(reader, writer, options) + if (snapshot !== undefined) return snapshot const generation = this.generation const activeStorage = this.getActiveUser().activeStorage const atomicCheckpoint = this._active?.access?.atomicSyncPages === true @@ -900,6 +1062,36 @@ export class WalletStorageManager implements sdk.WalletStorage { }) } + /** Bounded push on negotiated local snapshots, with the legacy serialized fallback. */ + async syncToWriterResumable( + auth: sdk.AuthId, + writer: sdk.WalletStorageProvider, + options: SyncSessionOptions = {} + ): Promise { + if (auth.identityKey !== this._authId.identityKey) throw new WERR_UNAUTHORIZED() + const writerSettings = await writer.makeAvailable() + await this.preflightManagedNetworks(writerSettings) + await this.getAuth() + const snapshot = await this.trySnapshotCopy(this.getActive(), writer, options) + if (snapshot !== undefined) return snapshot + return await this.runAsSync(async reader => { + const settings = reader.getSettings() + assertSyncNetwork(settings, writerSettings) + return await runPullSession( + { + reader, + writer, + activeStorage: this.getActiveUser().activeStorage, + atomicCheckpoint: false, + mode: 'exclusive', + loadRequest: () => this.loadSyncRequest(auth, writer, settings, writerSettings.storageIdentityKey), + commit: operation => operation() + }, + options + ) + }) + } + async syncToWriter( auth: sdk.AuthId, writer: sdk.WalletStorageProvider, @@ -907,12 +1099,33 @@ export class WalletStorageManager implements sdk.WalletStorage { log: string = '', progLog?: (s: string) => string ): Promise<{ inserts: number; updates: number; log: string }> { + if (auth.identityKey !== this._authId.identityKey) throw new WERR_UNAUTHORIZED() progLog ||= s => s const writerSettings = await writer.makeAvailable() await this.preflightManagedNetworks(writerSettings) if (activeSync != null) assertSyncNetwork(activeSync.getSettings(), writerSettings) + if (activeSync == null) { + await this.getAuth() + const snapshot = await this.trySnapshotCopy(this.getActive(), writer, { + maxRoughSize: 10000000, + onProgress: progress => { + log += this.committedPageLog(progress, progLog) + } + }) + if (snapshot !== undefined) + return { + inserts: snapshot.inserts, + updates: snapshot.updates, + log: + log + + progLog( + `syncToWriter ${snapshot.mode} complete: ${snapshot.inserts} inserts, ${snapshot.updates} updates\n` + ) + } + } + let inserts = 0 let updates = 0 @@ -962,6 +1175,26 @@ export class WalletStorageManager implements sdk.WalletStorage { async updateBackups(activeSync?: sdk.WalletStorageSync, progLog?: (s: string) => string): Promise { progLog ||= s => s const auth = await this.getAuth(true) + if (activeSync == null) { + const selected = { generation: this.generation, active: this.getActive() } + const backups = [...(this._backups as ManagedStorage[])] + let log = progLog(`BACKUP CURRENT ACTIVE TO ${backups.length} STORES\n`) + for (const backup of backups) { + const options: SyncSessionOptions = { + maxRoughSize: 10000000, + onProgress: progress => { + log += this.committedPageLog(progress, progLog) + } + } + const result = + (await this.trySnapshotCopy(selected.active, backup.storage, options, selected)) ?? + (await this.runLegacySnapshotFallback(selected.active, backup.storage, options, selected)) + log += progLog( + `${result.mode === 'paged' ? 'snapshot' : 'serialized'} complete: ${result.inserts} inserts, ${result.updates} updates\n` + ) + } + return log + } return await this.runAsSync(async sync => { let log = progLog(`BACKUP CURRENT ACTIVE TO ${(this._backups as ManagedStorage[]).length} STORES\n`) for (const backup of this._backups as ManagedStorage[]) { diff --git a/packages/wallet/wallet-toolbox/src/storage/index.all.ts b/packages/wallet/wallet-toolbox/src/storage/index.all.ts index 9e09b93f0..41dd9e82b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.all.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.all.ts @@ -33,3 +33,12 @@ export type { WalletSnapshotTable, WalletSnapshotTables } from './snapshot/WalletReadSnapshot' + +export { snapshotSyncTables } from './snapshot/SnapshotSync' +export type { + SnapshotSyncSource, + SnapshotSyncCheckpoint, + SnapshotSyncCommit, + SnapshotSyncStorage, + SnapshotSyncTable +} from './snapshot/SnapshotSync' diff --git a/packages/wallet/wallet-toolbox/src/storage/index.client.ts b/packages/wallet/wallet-toolbox/src/storage/index.client.ts index fcaa79c41..da778e40b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.client.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.client.ts @@ -24,3 +24,12 @@ export type { WalletSnapshotTable, WalletSnapshotTables } from './snapshot/WalletReadSnapshot' + +export { snapshotSyncTables } from './snapshot/SnapshotSync' +export type { + SnapshotSyncSource, + SnapshotSyncCheckpoint, + SnapshotSyncCommit, + SnapshotSyncStorage, + SnapshotSyncTable +} from './snapshot/SnapshotSync' diff --git a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts index e463ccaa4..a481d18db 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts @@ -22,3 +22,12 @@ export type { WalletSnapshotTable, WalletSnapshotTables } from './snapshot/WalletReadSnapshot' + +export { snapshotSyncTables } from './snapshot/SnapshotSync' +export type { + SnapshotSyncSource, + SnapshotSyncCheckpoint, + SnapshotSyncCommit, + SnapshotSyncStorage, + SnapshotSyncTable +} from './snapshot/SnapshotSync' diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.test.ts b/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.test.ts index 44ea1a162..63e92d176 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.test.ts @@ -161,3 +161,22 @@ test('exhausts providers once without accepting an invalid proof or partially ch expect(page).toEqual(before) expect(() => assertSyncProofReplacementAuthorized(page[0])).toThrow() }) + +test('validates packed proof bytes without expanding or changing them', async () => { + const f = fixture() + const rawTx = new Uint8Array(f.candidate.rawTx) + const merklePath = new Uint8Array(f.currentPath.toBinary()) + const candidate = { + ...f.candidate, + height: 101, + rawTx: rawTx as unknown as number[], + merklePath: merklePath as unknown as number[], + merkleRoot: f.currentRoot, + blockHash: asString(doubleSha256BE(f.currentHeader)), + index: 0 + } + await validateSyncProof(f.storage, candidate) + expect(candidate.rawTx).toBe(rawTx) + expect(candidate.merklePath).toBe(merklePath) + expect(() => assertSyncProofReplacementAuthorized(candidate)).not.toThrow() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.ts b/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.ts index 3feee30bf..5032eda3d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.ts @@ -114,10 +114,13 @@ export async function validateSyncProof(storage: SyncProofValidationStorage, can if (!Number.isSafeInteger(candidate.index) || candidate.index < 0) { invalidSyncProof('index must be a non-negative safe integer') } - if (!Array.isArray(candidate.rawTx) || candidate.rawTx.length === 0) { + if (!(candidate.rawTx instanceof Uint8Array || Array.isArray(candidate.rawTx)) || candidate.rawTx.length === 0) { invalidSyncProof('raw transaction is required') } - if (!Array.isArray(candidate.merklePath) || candidate.merklePath.length === 0) { + if ( + !(candidate.merklePath instanceof Uint8Array || Array.isArray(candidate.merklePath)) || + candidate.merklePath.length === 0 + ) { invalidSyncProof('Merkle path is required') } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts index 2cdbf1090..37c44ea5d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts @@ -415,6 +415,7 @@ describe('StorageServer JSON-RPC boundary', () => { 'openReadSnapshot', 'supportsRetainedReadSnapshot', 'openWalletReadSnapshot', + 'getSnapshotSync', 'supportsWalletReadSnapshot' ])( 'keeps local snapshot method %s outside the authenticated RPC surface', diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index 4e36da5f5..854446add 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Knex } from 'knex' +import { addSnapshotSyncTables, removeSnapshotSyncTables, SNAPSHOT_SYNC_MIGRATION } from './snapshotSyncMigration' import { DBType } from '../StorageReader' import { Chain } from '../../sdk/types' import { StorageKnex } from '../StorageKnex' @@ -11,6 +12,8 @@ import { LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS } from '../methods/managedChangePolicy' +export { SNAPSHOT_SYNC_MIGRATION } from './snapshotSyncMigration' + export const SYNC_TRANSFER_MIGRATION = '2026-09-09-001 add bounded sync transfers' export const AUTH_SESSION_MIGRATION = '2026-07-14-001 add shared auth sessions' @@ -93,6 +96,12 @@ export class KnexMigrations implements MigrationSource { } } + migrations[SNAPSHOT_SYNC_MIGRATION] = { + config: { transaction: true }, + up: addSnapshotSyncTables, + down: removeSnapshotSyncTables + } + migrations[SYNC_TRANSFER_MIGRATION] = { config: { transaction: true }, async up(knex) { diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityProvenTxReq.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityProvenTxReq.ts index 6ccdcdc36..533e6f288 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityProvenTxReq.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityProvenTxReq.ts @@ -26,11 +26,7 @@ export class EntityProvenTxReq extends EntityBase { return new EntityProvenTxReq(reqApi) } - static fromTxid( - txid: string, - rawTx: number[] | Uint8Array, - inputBEEF?: number[] | Uint8Array - ): EntityProvenTxReq { + static fromTxid(txid: string, rawTx: number[] | Uint8Array, inputBEEF?: number[] | Uint8Array): EntityProvenTxReq { const now = new Date() return new EntityProvenTxReq({ provenTxReqId: 0, @@ -626,6 +622,10 @@ export class EntityProvenTxReq extends EntityBase { syncMap: SyncMap, trx?: TrxToken ): Promise { + const previousBatch = this.batch + const previousHistory = this.apiHistory + const previousNotify = this.apiNotify + const previousUpdatedAt = this.updated_at.getTime() if (!this.batch && ei.batch) this.batch = ei.batch else if (this.batch && ei.batch && this.batch !== ei.batch) { throw new WERR_INTERNAL('ProvenTxReq merge batch not equal.') @@ -635,6 +635,13 @@ export class EntityProvenTxReq extends EntityBase { this.mergeNotifyTransactionIds(ei, syncMap) this.updated_at = new Date(Math.max(ei.updated_at.getTime(), this.updated_at.getTime())) + if ( + this.batch === previousBatch && + this.apiHistory === previousHistory && + this.apiNotify === previousNotify && + this.updated_at.getTime() === previousUpdatedAt + ) + return false await storage.updateProvenTxReq(this.id, this.toApi(), trx) return false } diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntitySyncState.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntitySyncState.ts index b762b11b5..734d093af 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntitySyncState.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntitySyncState.ts @@ -1,3 +1,4 @@ +import { mergeSyncChunkEntities } from './mergeSyncChunkEntities' import { RequestSyncChunkArgs, SyncChunk, @@ -7,24 +8,10 @@ import { WalletStorageSync } from '../../../sdk/WalletStorage.interfaces' import { WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' -import { maxDate, verifyId, verifyTruthy } from '../../../utility/utilityHelpers' +import { verifyId, verifyTruthy } from '../../../utility/utilityHelpers' import { TableSettings } from '../tables/TableSettings' import { TableSyncState } from '../tables/TableSyncState' import { createSyncMap, EntityBase, EntityStorage, SyncError, SyncMap } from './EntityBase' -import { EntityCertificate } from './EntityCertificate' -import { EntityCertificateField } from './EntityCertificateField' -import { EntityCommission } from './EntityCommission' -import { EntityOutput } from './EntityOutput' -import { EntityOutputBasket } from './EntityOutputBasket' -import { EntityOutputTag } from './EntityOutputTag' -import { EntityOutputTagMap } from './EntityOutputTagMap' -import { EntityProvenTx } from './EntityProvenTx' -import { EntityProvenTxReq } from './EntityProvenTxReq' -import { EntityTransaction } from './EntityTransaction' -import { EntityTxLabel } from './EntityTxLabel' -import { EntityTxLabelMap } from './EntityTxLabelMap' -import { EntityUser } from './EntityUser' -import { MergeEntity } from './MergeEntity' function formatSyncSection( heading: string, @@ -382,54 +369,16 @@ export class EntitySyncState extends EntityBase { updates: number inserts: number }> { - const mes = [ - new MergeEntity(chunk.provenTxs, EntityProvenTx.mergeFind, this.syncMap.provenTx), - new MergeEntity(chunk.outputBaskets, EntityOutputBasket.mergeFind, this.syncMap.outputBasket), - new MergeEntity(chunk.outputTags, EntityOutputTag.mergeFind, this.syncMap.outputTag), - new MergeEntity(chunk.txLabels, EntityTxLabel.mergeFind, this.syncMap.txLabel), - new MergeEntity(chunk.transactions, EntityTransaction.mergeFind, this.syncMap.transaction), - new MergeEntity(chunk.outputs, EntityOutput.mergeFind, this.syncMap.output), - new MergeEntity(chunk.txLabelMaps, EntityTxLabelMap.mergeFind, this.syncMap.txLabelMap), - new MergeEntity(chunk.outputTagMaps, EntityOutputTagMap.mergeFind, this.syncMap.outputTagMap), - new MergeEntity(chunk.certificates, EntityCertificate.mergeFind, this.syncMap.certificate), - new MergeEntity(chunk.certificateFields, EntityCertificateField.mergeFind, this.syncMap.certificateField), - new MergeEntity(chunk.commissions, EntityCommission.mergeFind, this.syncMap.commission), - new MergeEntity(chunk.provenTxReqs, EntityProvenTxReq.mergeFind, this.syncMap.provenTxReq) - ] - - let updates = 0 - let inserts = 0 - let maxUpdated_at: Date | undefined - let done = true - - // Merge User - if (chunk.user != null) { - const ei = chunk.user - const { found, eo } = await EntityUser.mergeFind(writer, this.userId, ei, trx) - if (found) { - if (await eo.mergeExisting(writer, args.since, ei, undefined, trx)) { - maxUpdated_at = maxDate(maxUpdated_at, ei.updated_at) - updates++ - } - } - } - - // Merge everything else... - for (const me of mes) { - const r = await me.merge(args.since, writer, this.userId, this.syncMap, trx) - // The counts become the offsets for the next chunk. - me.esm.count += me.stateArray?.length || 0 - updates += r.updates - inserts += r.inserts - maxUpdated_at = maxDate(maxUpdated_at, me.esm.maxUpdated_at) - // If any entity type either did not report results or if there were at least one, then we aren't done. - if (me.stateArray === undefined || me.stateArray.length > 0) done = false - } - + const { done, maxUpdated_at, updates, inserts } = await mergeSyncChunkEntities( + writer, + this.userId, + args.since, + chunk, + this.syncMap, + trx + ) if (done) { - // Next batch starts further in the future with offsets of zero. this.when = maxUpdated_at - for (const me of mes) me.esm.count = 0 } await this.updateStorage(writer, false, trx) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/mergeSyncChunkEntities.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/mergeSyncChunkEntities.ts new file mode 100644 index 000000000..3263d5558 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/mergeSyncChunkEntities.ts @@ -0,0 +1,74 @@ +import type { SyncChunk, TrxToken } from '../../../sdk/WalletStorage.interfaces' +import { maxDate } from '../../../utility/utilityHelpers' +import type { EntityStorage, SyncMap } from './EntityBase' +import { EntityCertificate } from './EntityCertificate' +import { EntityCertificateField } from './EntityCertificateField' +import { EntityCommission } from './EntityCommission' +import { EntityOutput } from './EntityOutput' +import { EntityOutputBasket } from './EntityOutputBasket' +import { EntityOutputTag } from './EntityOutputTag' +import { EntityOutputTagMap } from './EntityOutputTagMap' +import { EntityProvenTx } from './EntityProvenTx' +import { EntityProvenTxReq } from './EntityProvenTxReq' +import { EntityTransaction } from './EntityTransaction' +import { EntityTxLabel } from './EntityTxLabel' +import { EntityTxLabelMap } from './EntityTxLabelMap' +import { EntityUser } from './EntityUser' +import { MergeEntity } from './MergeEntity' + +/** Merge a bounded set of rows using a caller-owned ID map and transaction. No checkpoint is persisted here. */ +export async function mergeSyncChunkEntities( + writer: EntityStorage, + userId: number, + since: Date | undefined, + chunk: SyncChunk, + syncMap: SyncMap, + trx?: TrxToken +): Promise<{ done: boolean; maxUpdated_at: Date | undefined; updates: number; inserts: number }> { + const mes = [ + new MergeEntity(chunk.provenTxs, EntityProvenTx.mergeFind, syncMap.provenTx), + new MergeEntity(chunk.outputBaskets, EntityOutputBasket.mergeFind, syncMap.outputBasket), + new MergeEntity(chunk.outputTags, EntityOutputTag.mergeFind, syncMap.outputTag), + new MergeEntity(chunk.txLabels, EntityTxLabel.mergeFind, syncMap.txLabel), + new MergeEntity(chunk.transactions, EntityTransaction.mergeFind, syncMap.transaction), + new MergeEntity(chunk.outputs, EntityOutput.mergeFind, syncMap.output), + new MergeEntity(chunk.txLabelMaps, EntityTxLabelMap.mergeFind, syncMap.txLabelMap), + new MergeEntity(chunk.outputTagMaps, EntityOutputTagMap.mergeFind, syncMap.outputTagMap), + new MergeEntity(chunk.certificates, EntityCertificate.mergeFind, syncMap.certificate), + new MergeEntity(chunk.certificateFields, EntityCertificateField.mergeFind, syncMap.certificateField), + new MergeEntity(chunk.commissions, EntityCommission.mergeFind, syncMap.commission), + new MergeEntity(chunk.provenTxReqs, EntityProvenTxReq.mergeFind, syncMap.provenTxReq) + ] + + let updates = 0 + let inserts = 0 + let maxUpdated_at: Date | undefined + let done = true + + // Merge User + if (chunk.user != null) { + const ei = chunk.user + const { found, eo } = await EntityUser.mergeFind(writer, userId, ei, trx) + if (found) { + if (await eo.mergeExisting(writer, since, ei, undefined, trx)) { + maxUpdated_at = maxDate(maxUpdated_at, ei.updated_at) + updates++ + } + } + } + + // Merge everything else... + for (const me of mes) { + const r = await me.merge(since, writer, userId, syncMap, trx) + // The counts become the offsets for the next chunk. + me.esm.count += me.stateArray?.length || 0 + updates += r.updates + inserts += r.inserts + maxUpdated_at = maxDate(maxUpdated_at, me.esm.maxUpdated_at) + // If any entity type either did not report results or if there were at least one, then we aren't done. + if (me.stateArray === undefined || me.stateArray.length > 0) done = false + } + + if (done) for (const me of mes) me.esm.count = 0 + return { done, maxUpdated_at, updates, inserts } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.test.ts new file mode 100644 index 000000000..746efac2f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.test.ts @@ -0,0 +1,112 @@ +import { knex, type Knex } from 'knex' +import { addSnapshotSyncTables, removeSnapshotSyncTables } from './snapshotSyncMigration' + +const names = ['snapshot_sync_sessions', 'snapshot_sync_ids', 'snapshot_sync_primary_epochs'] + +test('SQLite migration preserves user data, counts primary transitions and removes only its auxiliary objects', async () => { + const k = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + try { + await k.raw('PRAGMA foreign_keys = ON') + await k.schema.createTable('users', table => { + table.increments('userId') + table.string('activeStorage') + }) + await k('users').insert({ userId: 1, activeStorage: 'source' }) + await addSnapshotSyncTables(k) + await addSnapshotSyncTables(k) + await k('users').where({ userId: 1 }).update({ userId: 1 }) + await k('users').where({ userId: 1 }).update({ activeStorage: 'source' }) + expect(await k('snapshot_sync_primary_epochs')).toHaveLength(0) + await k('users').where({ userId: 1 }).update({ activeStorage: 'destination' }) + await k('users').where({ userId: 1 }).update({ activeStorage: 'source' }) + expect(await k('snapshot_sync_primary_epochs')).toEqual([{ userId: 1, epoch: 2 }]) + await expect(k('snapshot_sync_primary_epochs').insert({ userId: 999, epoch: 1 })).rejects.toThrow('FOREIGN KEY') + const columns = await k('snapshot_sync_ids').columnInfo() + expect(Object.keys(columns)).toEqual(['userId', 'sourceStorageIdentityKey', 'entity', 'incomingId', 'localId']) + await removeSnapshotSyncTables(k) + await removeSnapshotSyncTables(k) + expect(await k('users')).toEqual([{ userId: 1, activeStorage: 'source' }]) + for (const name of names) expect(await k.schema.hasTable(name)).toBe(false) + } finally { + await k.destroy() + } +}) + +test('MySQL migration repairs separately committed DDL without duplicating tables, keys or its trigger', async () => { + // Compile every builder against real Knex MySQL SQL generation. The native + // MySQL fixture separately qualifies execution and trigger semantics. + const compiler = knex({ client: 'mysql2' }) + const tables = new Set() + const foreignKeys = new Set() + const ddl: string[] = [] + let trigger = false + let interrupted = false + const database = { + client: { config: { client: 'mysql2' } }, + schema: { + hasTable: async (name: string) => tables.has(name), + createTable: async (name: string, build: (table: Knex.CreateTableBuilder) => void) => { + ddl.push( + ...compiler.schema + .createTable(name, build) + .toSQL() + .map(query => query.sql) + ) + tables.add(name) + }, + table: async (name: string, build: (table: Knex.AlterTableBuilder) => void) => { + if (!interrupted) { + interrupted = true + throw new Error('interrupted after table DDL') + } + ddl.push( + ...compiler.schema + .table(name, build) + .toSQL() + .map(query => query.sql) + ) + foreignKeys.add(name + '_user') + } + }, + raw: async (sql: string, bindings?: string[]) => { + if (sql.includes('TABLE_CONSTRAINTS')) { + expect(bindings).toEqual([expect.stringMatching(/^snapshot_sync_/), expect.stringMatching(/_user$/)]) + return [foreignKeys.has(bindings![1]) ? [{ name: bindings![1] }] : []] + } + if (sql.includes('information_schema.TRIGGERS')) { + expect(bindings).toEqual(['snapshot_sync_primary_change']) + return [trigger ? [{ name: 'snapshot_sync_primary_change' }] : []] + } + if (sql.startsWith('CREATE TRIGGER snapshot_sync_primary_change')) { + ddl.push(sql) + trigger = true + return [] + } + throw new Error('Unexpected migration query') + } + } as unknown as Knex + try { + await expect(addSnapshotSyncTables(database)).rejects.toThrow('interrupted after table DDL') + expect([...tables]).toEqual(names) + expect(foreignKeys.size).toBe(0) + await addSnapshotSyncTables(database) + const completed = [...ddl] + await addSnapshotSyncTables(database) + expect(ddl).toEqual(completed) + expect(foreignKeys.size).toBe(3) + expect(trigger).toBe(true) + for (const sql of ddl.filter(sql => sql.startsWith('create table'))) + expect(sql).toContain('`userId` int unsigned not null') + const mappings = ddl.find(sql => sql.startsWith('create table `snapshot_sync_ids`'))! + expect(mappings).toContain('`incomingId` bigint unsigned not null') + expect(mappings).toContain('`localId` bigint unsigned not null') + expect(mappings).toContain('primary key (`userId`, `sourceStorageIdentityKey`, `entity`, `incomingId`)') + expect(ddl.filter(sql => sql.startsWith('alter table'))).toHaveLength(3) + for (const sql of ddl.filter(sql => sql.startsWith('alter table'))) + expect(sql).toContain('foreign key (`userId`) references `users` (`userId`)') + expect(ddl.at(-1)).toContain('NOT (OLD.activeStorage <=> NEW.activeStorage)') + expect(ddl.at(-1)).toContain('ON DUPLICATE KEY UPDATE epoch = epoch + 1') + } finally { + await compiler.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.ts new file mode 100644 index 000000000..27e6cc406 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.ts @@ -0,0 +1,92 @@ +import type { Knex } from 'knex' + +export const SNAPSHOT_SYNC_MIGRATION = '2026-09-30-001 add durable snapshot sync' + +/** Auxiliary tables only: preserve standard-table traversal and legacy sync-map JSON. */ +export async function addSnapshotSyncTables(knex: Knex): Promise { + const mysql = String(knex.client.config.client).includes('mysql') + const userColumn = (table: Knex.CreateTableBuilder): Knex.ColumnBuilder => { + const column = table.integer('userId').unsigned().notNullable() + // MySQL creates foreign keys in separate DDL. Install/recover them below. + if (!mysql) column.references('userId').inTable('users') + return column + } + // MySQL DDL commits implicitly. Creations are idempotent after interruption. + if (!(await knex.schema.hasTable('snapshot_sync_sessions'))) { + await knex.schema.createTable('snapshot_sync_sessions', table => { + userColumn(table) + table.string('sourceStorageIdentityKey', 130).notNullable() + table.string('sessionId', 64).notNullable() + table.string('snapshotId', 64).notNullable() + table.integer('version').notNullable() + table.bigInteger('sourceUserId').unsigned().notNullable() + table.string('identityKey', 130).notNullable() + table.string('destinationStorageIdentityKey', 130).notNullable() + table.string('chain', 8).notNullable() + table.string('activeStorage', 130).nullable() + table.string('sourceActiveStorage', 130).nullable() + table.string('sourceUserCreatedAt', 64).notNullable() + table.string('sourceUserUpdatedAt', 64).notNullable() + table.bigInteger('primaryEpoch').notNullable() + table.bigInteger('expiresAt').notNullable() + table.integer('tableIndex').notNullable() + table.integer('sequence').notNullable() + table.text('cursor').nullable() + table.primary(['userId', 'sourceStorageIdentityKey']) + }) + } + if (!(await knex.schema.hasTable('snapshot_sync_ids'))) { + await knex.schema.createTable('snapshot_sync_ids', table => { + userColumn(table) + table.string('sourceStorageIdentityKey', 130).notNullable() + table.string('entity', 32).notNullable() + table.bigInteger('incomingId').unsigned().notNullable() + table.bigInteger('localId').unsigned().notNullable() + table.primary(['userId', 'sourceStorageIdentityKey', 'entity', 'incomingId']) + }) + } + if (!(await knex.schema.hasTable('snapshot_sync_primary_epochs'))) { + await knex.schema.createTable('snapshot_sync_primary_epochs', table => { + userColumn(table).primary() + table.bigInteger('epoch').notNullable() + }) + } + // A primary may change away and back to the same identity. A database-owned + // counter fences that ABA transition, including older/independent writers. + if (mysql) { + for (const table of ['snapshot_sync_sessions', 'snapshot_sync_ids', 'snapshot_sync_primary_epochs']) { + const constraint = table + '_user' + const [existing]: Array> = await knex.raw( + "SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ? AND CONSTRAINT_TYPE = 'FOREIGN KEY'", + [table, constraint] + ) + if (existing.length === 0) + await knex.schema.table(table, definition => { + definition.foreign('userId', constraint).references('userId').inTable('users') + }) + } + const [triggers]: Array> = await knex.raw( + 'SELECT TRIGGER_NAME AS name FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME = ?', + ['snapshot_sync_primary_change'] + ) + if (triggers.length === 0) + await knex.raw(`CREATE TRIGGER snapshot_sync_primary_change AFTER UPDATE ON users + FOR EACH ROW BEGIN IF NOT (OLD.activeStorage <=> NEW.activeStorage) THEN + INSERT INTO snapshot_sync_primary_epochs (userId, epoch) VALUES (NEW.userId, 1) + ON DUPLICATE KEY UPDATE epoch = epoch + 1; + END IF; END`) + } else { + await knex.raw(`CREATE TRIGGER IF NOT EXISTS snapshot_sync_primary_change AFTER UPDATE OF activeStorage ON users + WHEN OLD.activeStorage IS NOT NEW.activeStorage BEGIN + INSERT INTO snapshot_sync_primary_epochs (userId, epoch) VALUES (NEW.userId, 1) + ON CONFLICT(userId) DO UPDATE SET epoch = epoch + 1; + END`) + } +} + +export async function removeSnapshotSyncTables(knex: Knex): Promise { + await knex.raw('DROP TRIGGER IF EXISTS snapshot_sync_primary_change') + await knex.schema.dropTableIfExists('snapshot_sync_primary_epochs') + await knex.schema.dropTableIfExists('snapshot_sync_ids') + await knex.schema.dropTableIfExists('snapshot_sync_sessions') +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts new file mode 100644 index 000000000..da85c3fb5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts @@ -0,0 +1,246 @@ +import { knex } from 'knex' +import { StorageKnex, type StorageKnexOptions } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import type { WalletReadSnapshot } from './WalletReadSnapshot' + +function gate() { + let resolve!: () => void + const promise = new Promise(yes => { + resolve = yes + }) + return { promise, resolve } +} +const pendingOperations: Promise[] = [] +function observe(operation: Promise): Promise { + // Keep secondary cleanup failures attached when a preceding fault assertion + // fails. Every primary result is still awaited/asserted by its owning test. + void operation.catch(() => undefined) + pendingOperations.push(operation) + return operation +} +async function waitForBoundary(boundary: Promise, operation: Promise): Promise { + await Promise.race([ + boundary, + operation.then(() => { + throw new Error('Operation completed before the required concurrency boundary') + }) + ]) +} +const stores: StorageKnex[] = [] +function mysql(telemetry?: StorageKnexOptions['telemetry']) { + const store = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + telemetry, + knex: knex({ + client: 'mysql2', + connection: { host: '127.0.0.1', database: 'synthetic', user: 'unit', password: 'synthetic-only' }, + pool: { min: 0, max: 1 } + }) + }) + stores.push(store) + return store +} +function view(): WalletReadSnapshot { + const closed = gate() + let open = true + return { + version: 1, + snapshotId: 'a'.repeat(64), + sourceStorage: {} as WalletReadSnapshot['sourceStorage'], + user: {} as WalletReadSnapshot['user'], + expiresAt: Date.now() + 10000, + get isOpen() { + return open + }, + closed: closed.promise, + close: async () => { + open = false + closed.resolve() + }, + readPage: jest.fn() + } +} +afterEach(async () => { + jest.restoreAllMocks() + for (const store of stores.splice(0)) await store.destroy() + await Promise.allSettled(pendingOperations.splice(0)) +}) + +test('the dedicated MySQL reader preserves hidden credentials and leaves original pool capacity intact', async () => { + const capture = jest.fn() + let enabled = true + const source = mysql({ sink: { capture }, enabled: () => enabled }) + const result = view() + const opening = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot').mockImplementation(async function ( + this: StorageKnex + ) { + const connection = this.knex.client.config.connection as object + const password = Object.getOwnPropertyDescriptor(connection, 'password')! + expect(password.value).toBe('synthetic-only') + expect(password.enumerable).toBe(false) + expect(this.knex.client.config.pool.max).toBe(1) + expect(this.getSnapshotSync()).toBeUndefined() + this.knex.emit('query', { __knexQueryUid: 'synthetic', method: 'select', sql: 'private data' }) + this.knex.emit('query-response', [], { __knexQueryUid: 'synthetic' }) + return result + }) + const opened = (await source.getSnapshotSync()!.openSource('identity'))! + const child = opening.mock.contexts[0] as StorageKnex + expect(child).not.toBe(source) + expect(source.knex.client.config.pool.max).toBe(1) + expect(capture).toHaveBeenCalled() + expect(JSON.stringify(capture.mock.calls)).not.toContain('private data') + enabled = false + expect(child.telemetry.enabled).toBe(false) + const cleanup = jest.spyOn(child, 'destroy') + await opened.close() + expect(cleanup).toHaveBeenCalledTimes(1) + expect(opened.isOpen).toBe(false) +}) + +test('dynamic connection providers and externally shared pools retain the legacy path', async () => { + const source = mysql() + const original = source.knex.client.config.connection + const open = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot') + source.knex.client.config.connection = async () => original + expect(await source.getSnapshotSync()!.openSource('identity')).toBeUndefined() + source.knex.client.config.connection = original + source.knex.client.config.connectionPool = { externallyOwned: true } + expect(await source.getSnapshotSync()!.openSource('identity')).toBeUndefined() + delete source.knex.client.config.connectionPool + source.knex.client.config.connection = { user: 'unit' } + expect(await source.getSnapshotSync()!.openSource('identity')).toBeUndefined() + expect(open).not.toHaveBeenCalled() +}) + +test.each([undefined, '', ':memory:', 'file:synthetic-snapshot'])( + 'SQLite filename %s cannot open an independent retained reader', + async filename => { + const source = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ client: 'better-sqlite3', connection: { filename: filename as string }, useNullAsDefault: true }) + }) + stores.push(source) + const opening = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot') + const query = jest.spyOn(source.knex.client, 'raw') + expect(await source.getSnapshotSync()!.openSource('identity')).toBeUndefined() + expect(opening).not.toHaveBeenCalled() + expect(query).not.toHaveBeenCalled() + } +) + +test('destroy fences a source that has not finished opening and drains its private reader', async () => { + const source = mysql() + const waiting = gate() + const started = gate() + const result = view() + const closing = jest.spyOn(result, 'close') + jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot').mockImplementation(async () => { + started.resolve() + await waiting.promise + return result + }) + const opening = observe(source.getSnapshotSync()!.openSource('identity')) + let destroying: Promise | undefined + try { + await waitForBoundary(started.promise, opening) + destroying = observe(source.destroy()) + } finally { + waiting.resolve() + } + await expect(opening).rejects.toThrow('destruction') + await destroying + expect(closing).toHaveBeenCalled() + expect(result.isOpen).toBe(false) +}) + +test('physical reader cleanup retains the admission slot until it finishes', async () => { + const source = mysql() + const result = view() + const waiting = gate() + const cleaning = gate() + const opening = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot').mockResolvedValue(result) + const opened = (await source.getSnapshotSync()!.openSource('identity'))! + const child = opening.mock.contexts[0] as StorageKnex + const original = child.destroy.bind(child) + jest.spyOn(child, 'destroy').mockImplementation(async () => { + cleaning.resolve() + await waiting.promise + await original() + }) + const closing = observe(opened.close()) + try { + await waitForBoundary(cleaning.promise, closing) + await expect(source.getSnapshotSync()!.openSource('identity')).rejects.toThrow('already has') + } finally { + waiting.resolve() + } + await closing +}) + +test('failed physical cleanup fences further source admission', async () => { + const source = mysql() + const result = view() + const opening = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot').mockResolvedValue(result) + const opened = (await source.getSnapshotSync()!.openSource('identity'))! + const child = opening.mock.contexts[0] as StorageKnex + const cleanup = jest.spyOn(child, 'destroy').mockRejectedValue(new Error('synthetic cleanup failure')) + await expect(opened.close()).rejects.toThrow('synthetic cleanup failure') + await expect(source.getSnapshotSync()!.openSource('identity')).rejects.toThrow('destruction') + cleanup.mockRestore() + await child.destroy() +}) + +test('monotonic setup expiry releases admission before allocating a reader even if wall time is unchanged', async () => { + const source = mysql() + const open = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot').mockResolvedValue(view()) + const wall = jest.spyOn(Date, 'now').mockReturnValue(2000000) + const monotonic = jest.spyOn(performance, 'now').mockReturnValueOnce(1).mockReturnValue(1001) + await expect(source.getSnapshotSync()!.openSource('identity', { lifetimeMs: 1000 })).rejects.toThrow( + 'expired during connection setup' + ) + expect(open).not.toHaveBeenCalled() + monotonic.mockRestore() + wall.mockRestore() + const fresh = (await source.getSnapshotSync()!.openSource('identity'))! + await fresh.close() + expect(open).toHaveBeenCalledTimes(1) +}) + +test.each([1, 3600000])( + 'the exact lifetime boundary %s and cancellation signal reach the dedicated reader', + async lifetimeMs => { + const source = mysql() + const controller = new AbortController() + jest.spyOn(Date, 'now').mockReturnValue(5000) + jest.spyOn(performance, 'now').mockReturnValue(100) + const open = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot').mockResolvedValue(view()) + const opened = (await source.getSnapshotSync()!.openSource('identity', { lifetimeMs, signal: controller.signal }))! + expect(open).toHaveBeenCalledWith('identity', { lifetimeMs, signal: controller.signal }) + await opened.close() + } +) + +test('wall-clock setup expiry also fences opening when the monotonic clock does not advance', async () => { + const source = mysql() + jest.spyOn(performance, 'now').mockReturnValue(100) + jest.spyOn(Date, 'now').mockReturnValueOnce(5000).mockReturnValue(6000) + const open = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot') + await expect(source.getSnapshotSync()!.openSource('identity', { lifetimeMs: 1000 })).rejects.toThrow( + 'expired during connection setup' + ) + expect(open).not.toHaveBeenCalled() +}) + +test.each([null, 7])('unsupported connection value %p refuses before constructing another reader', async connection => { + const source = mysql() + const original = source.knex.client.config.connection + source.knex.client.config.connection = connection + const open = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot') + try { + expect(await source.getSnapshotSync()!.openSource('identity')).toBeUndefined() + expect(open).not.toHaveBeenCalled() + } finally { + source.knex.client.config.connection = original + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts new file mode 100644 index 000000000..614882952 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts @@ -0,0 +1,314 @@ +import { SNAPSHOT_SYNC_MIGRATION } from '../schema/snapshotSyncMigration' +import { Random, Utils } from '@bsv/sdk' +import type { StorageKnex } from '../StorageKnex' +import type { SyncChunk, TrxToken } from '../../sdk/WalletStorage.interfaces' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' +import { verifyOne } from '../../utility/utilityHelpers' +import type { TableProvenTx, TableUser } from '../schema/tables' +import { mergeSyncChunkEntities } from '../schema/entities/mergeSyncChunkEntities' +import { sameSyncProof } from '../methods/validateSyncProof' +import { detachSnapshotSyncPage, loadSnapshotIdMap } from './SnapshotSyncRows' +import { + snapshotSyncTables, + type SnapshotSyncCheckpoint, + type SnapshotSyncCommit, + type SnapshotSyncSource, + type SnapshotSyncTable +} from './SnapshotSync' +import type { WalletSnapshotPage } from './WalletReadSnapshot' +import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' + +interface SessionRow { + version: 1 + userId: number + identityKey: string + sourceStorageIdentityKey: string + destinationStorageIdentityKey: string + sessionId: string + snapshotId: string + sourceUserId: number + chain: string + primaryEpoch: number + sourceActiveStorage: string | null + sourceUserCreatedAt: string + sourceUserUpdatedAt: string + activeStorage: string | null + expiresAt: number + sequence: number + tableIndex: number + cursor: string | null +} + +type ProofPreparation = (chunk: SyncChunk) => Promise> + +function checkpoint(row: SessionRow): SnapshotSyncCheckpoint { + return { + version: row.version, + sessionId: row.sessionId, + identityKey: row.identityKey, + sourceStorageIdentityKey: row.sourceStorageIdentityKey, + destinationStorageIdentityKey: row.destinationStorageIdentityKey, + snapshotId: row.snapshotId, + tableIndex: row.tableIndex, + sequence: row.sequence, + cursor: row.cursor == null ? undefined : JSON.parse(row.cursor), + done: row.tableIndex === snapshotSyncTables.length + } +} + +function notMatchingCheckpoint(actual: SnapshotSyncCheckpoint, expected: SnapshotSyncCheckpoint): boolean { + return ( + actual.version !== expected.version || + actual.sessionId !== expected.sessionId || + actual.identityKey !== expected.identityKey || + actual.sourceStorageIdentityKey !== expected.sourceStorageIdentityKey || + actual.destinationStorageIdentityKey !== expected.destinationStorageIdentityKey || + actual.snapshotId !== expected.snapshotId || + actual.tableIndex !== expected.tableIndex || + actual.sequence !== expected.sequence || + actual.done !== expected.done || + actual.cursor?.version !== expected.cursor?.version || + actual.cursor?.snapshotId !== expected.cursor?.snapshotId || + actual.cursor?.table !== expected.cursor?.table || + JSON.stringify(actual.cursor?.after) !== JSON.stringify(expected.cursor?.after) + ) +} + +function requireLiveSource(expiresAt: number, stage: 'session admission' | 'destination commit'): void { + if (Date.now() >= expiresAt) throw new SnapshotResourceLimitError(`Snapshot source expired before ${stage}`) +} + +function validateSource(source: SnapshotSyncSource): void { + if ( + source.version !== 1 || + !/^[a-f0-9]{64}$/.test(source.snapshotId) || + !/^(02|03)[a-f0-9]{64}$/i.test(source.user.identityKey) || + !Number.isSafeInteger(source.user.userId) || + source.user.userId < 1 || + !Number.isSafeInteger(source.expiresAt) || + source.expiresAt > Date.now() + 3600000 || + typeof source.sourceStorage.storageIdentityKey !== 'string' || + source.sourceStorage.storageIdentityKey.length === 0 || + source.sourceStorage.storageIdentityKey.length > 130 + ) { + throw new WERR_INVALID_PARAMETER('source', 'a live version-one wallet snapshot with a bound profile and source') + } + requireLiveSource(source.expiresAt, 'session admission') +} + +/** Destination-authoritative state. Every session mutation locks the user before its session to preserve one lock order. */ +export class KnexSnapshotSyncDestination { + constructor( + private readonly storage: StorageKnex, + private readonly prepareProofs: ProofPreparation + ) {} + + async supportsDestination(): Promise { + const k = this.storage.knex + if (!(await k.schema.hasTable('knex_migrations'))) return false + return (await k('knex_migrations').select('name').where({ name: SNAPSHOT_SYNC_MIGRATION }).first()) !== undefined + } + + private async lockUser(identityKey: string, trx: TrxToken): Promise { + const k = this.storage.toDb(trx) + // Acquire the write lock before reading on both SQLite (deferred transaction) + // and MySQL. Primary-selection updates and sibling processes use this same row. + await k('users') + .where({ identityKey }) + .update({ userId: k.ref('userId') }) + return verifyOne(await this.storage.findUsers({ partial: { identityKey }, trx })) + } + + private async primaryEpoch(userId: number, trx: TrxToken): Promise { + const row: { epoch: number } | undefined = await this.storage + .toDb(trx)('snapshot_sync_primary_epochs') + .where({ userId }) + .first() + const epoch = Number(row?.epoch ?? 0) + if (!Number.isSafeInteger(epoch) || epoch < 0) throw new WERR_INVALID_OPERATION('Invalid primary epoch') + return epoch + } + + async checkpoint(identityKey: string, sourceStorageIdentityKey: string): Promise { + const user = await this.storage.findUserByIdentityKey(identityKey) + if (user === undefined) return undefined + const row: SessionRow | undefined = await this.storage + .knex('snapshot_sync_sessions') + .where({ userId: user.userId, sourceStorageIdentityKey }) + .first() + return row === undefined ? undefined : checkpoint(row) + } + + async begin(input: SnapshotSyncSource, activeStorage: string | undefined): Promise { + // Snapshot all caller-controlled values before the first asynchronous boundary. + validateSource(input) + const source = { + ...input, + sourceStorage: { ...input.sourceStorage }, + user: { + ...input.user, + created_at: new Date(input.user.created_at), + updated_at: new Date(input.user.updated_at), + activeStorage + } + } + const sourceUserCreatedAt = source.user.created_at.toISOString() + const sourceUserUpdatedAt = source.user.updated_at.toISOString() + if (!(await this.supportsDestination())) + throw new WERR_INVALID_OPERATION('Snapshot sync destination requires the version-one migration') + const settings = await this.storage.makeAvailable() + if ( + source.sourceStorage.chain !== settings.chain || + source.sourceStorage.storageIdentityKey === settings.storageIdentityKey + ) { + throw new WERR_INVALID_PARAMETER('source', 'a different storage on the destination network') + } + await this.storage.findOrInsertUser(source.user.identityKey) + return await this.storage.transaction(async trx => { + const user = await this.lockUser(source.user.identityKey, trx) + const k = this.storage.toDb(trx) + const scope = { userId: user.userId, sourceStorageIdentityKey: source.sourceStorage.storageIdentityKey } + const previous: SessionRow | undefined = await k('snapshot_sync_sessions').where(scope).first() + if (previous?.snapshotId === source.snapshotId) { + if ( + previous.sourceUserId !== source.user.userId || + previous.sourceActiveStorage !== (activeStorage ?? null) || + previous.sourceUserCreatedAt !== sourceUserCreatedAt || + previous.sourceUserUpdatedAt !== sourceUserUpdatedAt || + previous.chain !== settings.chain || + previous.destinationStorageIdentityKey !== settings.storageIdentityKey || + previous.version !== source.version || + previous.activeStorage !== (user.activeStorage ?? null) || + Number(previous.primaryEpoch) !== (await this.primaryEpoch(user.userId, trx)) || + Number(previous.expiresAt) !== source.expiresAt + ) { + throw new WERR_INVALID_OPERATION('Snapshot session binding changed or expired; open a new source view') + } + requireLiveSource(Number(previous.expiresAt), 'session admission') + return checkpoint(previous) + } + requireLiveSource(source.expiresAt, 'session admission') + // Profile metadata is merged with the first page, so cancellation before + // any page leaves the selected primary untouched and update counts agree + // with the durable merge outcome. + const row: SessionRow = { + ...scope, + version: 1, + sessionId: Utils.toHex(Random(32)), + snapshotId: source.snapshotId, + sourceUserId: source.user.userId, + identityKey: source.user.identityKey, + destinationStorageIdentityKey: settings.storageIdentityKey, + chain: settings.chain, + activeStorage: user.activeStorage ?? null, + sourceActiveStorage: activeStorage ?? null, + sourceUserCreatedAt, + sourceUserUpdatedAt, + primaryEpoch: await this.primaryEpoch(user.userId, trx), + expiresAt: source.expiresAt, + tableIndex: 0, + sequence: 0, + cursor: null + } + await k('snapshot_sync_sessions').insert(row).onConflict(['userId', 'sourceStorageIdentityKey']).merge(row) + return checkpoint(row) + }) + } + + async prepare( + input: SnapshotSyncCheckpoint, + page: WalletSnapshotPage + ): Promise<() => Promise> { + const expected: SnapshotSyncCheckpoint = { + ...input, + cursor: input.cursor === undefined ? undefined : { ...input.cursor, after: [...input.cursor.after] } + } + if ( + expected.version !== 1 || + !/^[a-f0-9]{64}$/.test(expected.sessionId) || + !Number.isSafeInteger(expected.sequence) || + expected.sequence < 0 || + !Number.isSafeInteger(expected.tableIndex) || + expected.tableIndex < 0 + ) { + throw new WERR_INVALID_PARAMETER('checkpoint', 'a version-one durable snapshot checkpoint') + } + const detached = detachSnapshotSyncPage(expected, page) + const proofs = await this.prepareProofs(detached.chunk) + let consumed = false + return async () => { + if (consumed) + throw new WERR_INVALID_OPERATION('Prepared snapshot page already consumed; read its durable checkpoint') + consumed = true + return await this.storage.transaction(async trx => { + const user = await this.lockUser(expected.identityKey, trx) + const k = this.storage.toDb(trx) + const scope = { userId: user.userId, sourceStorageIdentityKey: expected.sourceStorageIdentityKey } + const row: SessionRow | undefined = await k('snapshot_sync_sessions').where(scope).first() + if ( + row === undefined || + notMatchingCheckpoint(checkpoint(row), expected) || + row.activeStorage !== (user.activeStorage ?? null) || + Number(row.primaryEpoch) !== (await this.primaryEpoch(user.userId, trx)) || + row.chain !== this.storage.chain || + row.destinationStorageIdentityKey !== this.storage.getSettings().storageIdentityKey + ) { + throw new WERR_INVALID_OPERATION('Snapshot session changed or expired; resume from its durable checkpoint') + } + requireLiveSource(Number(row.expiresAt), 'destination commit') + await this.verifyProofs(proofs, trx) + const mappings = await loadSnapshotIdMap( + k, + scope, + row.sourceUserId, + snapshotSyncTables[row.tableIndex], + detached.chunk + ) + if (row.sequence === 0) + detached.chunk.user = { + userId: row.sourceUserId, + identityKey: row.identityKey, + created_at: new Date(row.sourceUserCreatedAt), + updated_at: new Date(row.sourceUserUpdatedAt), + // TableUser retains its legacy declaration although an unselected + // primary is represented as undefined by storage adapters. + activeStorage: (row.sourceActiveStorage ?? undefined) as string + } + const result = await mergeSyncChunkEntities( + this.storage, + user.userId, + undefined, + detached.chunk, + mappings.map, + trx + ) + await mappings.persist() + const currentUser = verifyOne(await this.storage.findUsers({ partial: { identityKey: row.identityKey }, trx })) + row.primaryEpoch = await this.primaryEpoch(user.userId, trx) + row.activeStorage = currentUser.activeStorage ?? null + row.sequence++ + row.tableIndex = detached.nextTable + row.cursor = detached.nextCursor + await k('snapshot_sync_sessions').where(scope).update({ + sequence: row.sequence, + tableIndex: row.tableIndex, + cursor: row.cursor, + primaryEpoch: row.primaryEpoch, + activeStorage: row.activeStorage + }) + return { checkpoint: checkpoint(row), inserts: result.inserts, updates: result.updates } + }) + } + } + + private async verifyProofs(proofs: Map, trx: TrxToken): Promise { + for (const previous of [...proofs.values()].sort((left, right) => left.txid.localeCompare(right.txid))) { + const rows = await this.storage.findProvenTxs({ partial: { txid: previous.txid }, trx }) + if (rows.length !== 1 || !sameSyncProof(rows[0], previous)) { + throw new WERR_INVALID_OPERATION( + 'Proof changed during snapshot preparation; resume from its durable checkpoint' + ) + } + } + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index f2538b59e..4d91d3ad7 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -1,3 +1,4 @@ +import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' import { Random, Utils } from '@bsv/sdk' import type { Knex } from 'knex' import type { StorageKnex } from '../StorageKnex' @@ -219,7 +220,7 @@ function boundedPrefix( if (!Number.isSafeInteger(bytes) || bytes < 0) throw new WERR_INVALID_OPERATION('Invalid snapshot row size') if (payloadBytes + bytes > maxBytes) { if (count === 0) - throw new WERR_INVALID_OPERATION('Snapshot row exceeds maxBytes; large-value streaming is required') + throw new SnapshotResourceLimitError('Snapshot row exceeds maxBytes; large-value streaming is required') break } payloadBytes += bytes diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts index a56cb4ef6..d3c6eac72 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts @@ -1,3 +1,4 @@ +import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' import type { TrxToken } from '../../sdk/WalletStorage.interfaces' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' @@ -88,7 +89,7 @@ export function retainReadSnapshot( const abort = (): void => stop(new WERR_INVALID_OPERATION('Retained read snapshot was cancelled')) const checkDeadline = (): void => { if (Date.now() >= expiresAt || performance.now() - startedAt >= lifetimeMs) { - stop(new WERR_INVALID_OPERATION('Retained read snapshot expired')) + stop(new SnapshotResourceLimitError('Retained read snapshot expired')) } } const assertOpen = (): void => { @@ -162,7 +163,7 @@ export function retainReadSnapshot( signal?.addEventListener('abort', abort, { once: true }) if (signal?.aborted === true) abort() if (stopReason === undefined) { - timer = setTimeout(() => stop(new WERR_INVALID_OPERATION('Retained read snapshot expired')), lifetimeMs) + timer = setTimeout(() => stop(new SnapshotResourceLimitError('Retained read snapshot expired')), lifetimeMs) } // Let the provider reserve its capacity slot before any acquisition hook can re-enter. void Promise.resolve().then(finish) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotResourceLimitError.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotResourceLimitError.ts new file mode 100644 index 000000000..19367f726 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotResourceLimitError.ts @@ -0,0 +1,4 @@ +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' + +/** A safe source/page admission limit, distinct from malformed data or a changed session. */ +export class SnapshotResourceLimitError extends WERR_INVALID_OPERATION {} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts new file mode 100644 index 000000000..a44df4ce4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts @@ -0,0 +1,1387 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { WalletStorageManager } from '../WalletStorageManager' +import { snapshotSyncTables, type SnapshotSyncCheckpoint, type SnapshotSyncStorage } from './SnapshotSync' +import type { WalletReadSnapshot } from './WalletReadSnapshot' +import { runSnapshotSyncSession } from './runSnapshotSyncSession' +import { KnexSnapshotSyncDestination } from './KnexSnapshotSyncDestination' +import { runInSeries } from '../../utility/runInSeries' +import { WERR_UNAUTHORIZED } from '../../sdk/WERR_errors' + +const identity = '02' + '11'.repeat(32) +const foreignIdentity = '03' + '22'.repeat(32) +const when = new Date('2026-01-01T00:00:00.000Z') +const stores: StorageKnex[] = [] +const directories: string[] = [] +function deferred() { + let resolve!: () => void + const promise = new Promise(r => { + resolve = r + }) + return { promise, resolve } +} +const pendingOperations: Promise[] = [] +function observe(operation: Promise): Promise { + // Keep secondary cleanup failures attached when a preceding fault assertion + // fails. Every primary result is still awaited/asserted by its owning test. + void operation.catch(() => undefined) + pendingOperations.push(operation) + return operation +} +async function waitForBoundary(boundary: Promise, operation: Promise): Promise { + await Promise.race([ + boundary, + operation.then(() => { + throw new Error('Operation completed before the required concurrency boundary') + }) + ]) +} +async function fixture(count = 130, wal = true, disabled?: 'source' | 'destination') { + const directory = await mkdtemp(join(tmpdir(), 'wallet-snapshot-sync-')) + directories.push(directory) + async function open(name: string, snapshotSync = true) { + const store = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + snapshotSync, + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, `${name}.sqlite`) }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 1000 + }) + }) + stores.push(store) + if (wal) await store.knex.raw('PRAGMA journal_mode = WAL') + await store.migrate(name, name) + await store.makeAvailable() + const { user } = await store.findOrInsertUser(identity) + await store.updateUser(user.userId, { activeStorage: 'source' }) + return store + } + const source = await open('source', disabled !== 'source') + const destination = await open('destination', disabled !== 'destination') + const { user: foreign } = await source.findOrInsertUser(foreignIdentity) + const user = (await source.findUserByIdentityKey(identity))! + await runInSeries( + Array.from({ length: count }, (_, index) => index), + async index => { + await source.insertTxLabel({ + txLabelId: 0, + userId: user.userId, + created_at: when, + updated_at: when, + label: `label-${index}`, + isDeleted: index % 3 === 0 + }) + await source.insertTxLabel({ + txLabelId: 0, + userId: foreign.userId, + created_at: when, + updated_at: when, + label: `foreign-${index}`, + isDeleted: false + }) + } + ) + const manager = new WalletStorageManager(identity, source, [destination]) + await manager.makeAvailable() + return { source, destination, manager, user, open } +} +async function advance( + view: WalletReadSnapshot, + destination: SnapshotSyncStorage, + target: number +): Promise { + let checkpoint = await destination.begin(view, view.user.activeStorage) + while (checkpoint.tableIndex < target) { + const page = await view.readPage(snapshotSyncTables[checkpoint.tableIndex], checkpoint.cursor) + const next = (await (await destination.prepare(checkpoint, page))()).checkpoint + expect(next.sequence).toBe(checkpoint.sequence + 1) + expect(next.tableIndex).toBe(checkpoint.tableIndex + Number(page.done)) + checkpoint = next + } + return checkpoint +} +afterEach(async () => { + jest.restoreAllMocks() + await runInSeries(stores.splice(0), store => store.destroy()) + await Promise.allSettled(pendingOperations.splice(0)) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) + +test('ordinary backup yields during preparation while its coherent view excludes foreground changes', async () => { + const { source, destination, manager, user } = await fixture() + const legacy = jest.spyOn(destination, 'processSyncChunk') + const gate = deferred() + const preparing = deferred() + const capability = destination.getSnapshotSync()! + let held = false + jest.spyOn(destination, 'getSnapshotSync').mockReturnValue({ + ...capability, + prepare: async (checkpoint, page) => { + const apply = await capability.prepare(checkpoint, page) + if (checkpoint.tableIndex === 3 && !held) { + held = true + preparing.resolve() + await gate.promise + } + return apply + } + }) + const backup = observe(manager.updateBackups()) + try { + await waitForBoundary(preparing.promise, backup) + await manager.runAsWriter(async () => { + await source.findOrInsertTxLabel(user.userId, 'foreground-after-view') + }) + } finally { + gate.resolve() + } + expect(await backup).toContain('snapshot complete') + const copied = await destination.findTxLabels({ partial: {} }) + expect(copied).toHaveLength(130) + expect(copied.every(row => row.label.startsWith('label-'))).toBe(true) + expect(copied.filter(row => row.isDeleted)).toHaveLength(44) + expect(legacy).not.toHaveBeenCalled() + expect(await destination.knex('sync_states')).toHaveLength(0) + expect(await destination.knex('snapshot_sync_ids').where({ entity: 'txLabel' })).toHaveLength(130) + await manager.updateBackups() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(131) +}) + +test('lost acknowledgement resumes the durable cursor and rejects a concurrently prepared stale page', async () => { + const { source, destination } = await fixture(5) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + const before = await advance(view, writer, 3) + const page = await view.readPage('txLabels', before.cursor, { maxRows: 2 }) + const apply = await writer.prepare(before, page) + const stale = await writer.prepare(before, page) + await apply() + const recovered = (await writer.checkpoint(identity, 'source'))! + expect(recovered.sequence).toBe(before.sequence + 1) + expect(recovered.cursor).toEqual(page.cursor) + expect(await writer.begin(view, view.user.activeStorage)).toEqual(recovered) + await expect(stale()).rejects.toThrow('session changed') + await expect(apply()).rejects.toThrow('already consumed') + const rest = await runSnapshotSyncSession( + { view, destination: writer, activeStorage: view.user.activeStorage, commit: op => op() }, + { maxItems: 2 } + ) + expect(rest.snapshotCheckpoint?.done).toBe(true) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(5) + await view.close() +}) + +test('page failure rolls data, mappings and checkpoint back together', async () => { + const { source, destination } = await fixture(3) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + const before = await advance(view, writer, 3) + await destination.knex.raw( + "CREATE TRIGGER reject_checkpoint BEFORE UPDATE ON snapshot_sync_sessions BEGIN SELECT RAISE(ABORT, 'checkpoint disk failure'); END" + ) + await expect((await writer.prepare(before, await view.readPage('txLabels')))()).rejects.toThrow( + 'checkpoint disk failure' + ) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) + expect(await destination.knex('snapshot_sync_ids').where({ entity: 'txLabel' })).toHaveLength(0) + expect(await writer.checkpoint(identity, 'source')).toEqual(before) + await destination.knex.raw('DROP TRIGGER reject_checkpoint') + await ( + await writer.prepare(before, await view.readPage('txLabels')) + )() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(3) + await view.close() +}) + +test.each(['before-row', 'before-checkpoint', 'before-commit', 'after-commit'] as const)( + 'a process killed %s recovers the atomic page outcome from its durable database', + async boundary => { + const { source, destination } = await fixture(3) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + const before = await advance(view, writer, 3) + const page = await view.readPage('txLabels', before.cursor, { maxRows: 2 }) + const filename = (destination.knex.client.config.connection as { filename: string }).filename + // Use the built package in a separate process: SIGKILL bypasses transaction + // catch/finally handlers, exercising the database's actual crash recovery. + const child = spawn( + process.execPath, + [ + '-e', + ` + const { knex } = require('knex') + const { StorageKnex } = require('./out/src/storage/StorageKnex.js') + const input = JSON.parse(process.argv[1], (key, value) => + key === 'created_at' || key === 'updated_at' ? new Date(value) : value) + const storage = new StorageKnex({ ...StorageKnex.defaultOptions(), chain: 'test', + knex: knex({ client: 'better-sqlite3', connection: { filename: input.filename }, + useNullAsDefault: true, pool: { min: 1, max: 1 } }) }) + async function hold() { process.send('at-boundary'); await new Promise(() => {}) } + async function run() { + await storage.makeAvailable() + if (input.boundary === 'before-row') { + const insert = storage.insertTxLabel.bind(storage) + storage.insertTxLabel = async (...args) => { await hold(); return insert(...args) } + } + if (input.boundary === 'before-checkpoint') { + const find = storage.findUsers.bind(storage) + let reads = 0 + storage.findUsers = async (...args) => { + if (++reads === 2) await hold() + return find(...args) + } + } + if (input.boundary === 'before-commit') { + const transaction = storage.transaction.bind(storage) + storage.transaction = (work, token) => transaction(async trx => { + const result = await work(trx) + await hold() + return result + }, token) + } + await (await storage.getSnapshotSync().prepare(input.before, input.page))() + if (input.boundary === 'after-commit') await hold() + throw new Error('Did not reach the requested boundary') + } + run().catch(error => { process.send({ error: error.message }); process.exitCode = 1 }) + `, + JSON.stringify({ filename, boundary, before, page }) + ], + { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] } + ) + let diagnostic = '' + child.stderr!.on('data', data => { + diagnostic += String(data) + }) + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Child did not reach ${boundary}: ${diagnostic}`)), 10000) + child.once('message', message => { + clearTimeout(timer) + if (message === 'at-boundary') resolve() + else reject(new Error(JSON.stringify(message))) + }) + child.once('error', error => { + clearTimeout(timer) + reject(error) + }) + child.once('exit', () => { + clearTimeout(timer) + reject(new Error(`Child exited early: ${diagnostic}`)) + }) + }) + const exited = once(child, 'exit') + expect(child.kill('SIGKILL')).toBe(true) + expect(await exited).toEqual([null, 'SIGKILL']) + const committed = boundary === 'after-commit' + const recovered = (await writer.checkpoint(identity, 'source'))! + expect(recovered.sequence).toBe(before.sequence + Number(committed)) + expect(recovered.cursor).toEqual(committed ? page.cursor : before.cursor) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(committed ? 2 : 0) + expect(await destination.knex('snapshot_sync_ids').where({ entity: 'txLabel' })).toHaveLength(committed ? 2 : 0) + expect((await destination.knex.raw('PRAGMA integrity_check'))[0].integrity_check).toBe('ok') + expect(await destination.knex.raw('PRAGMA foreign_key_check')).toEqual([]) + await runSnapshotSyncSession( + { view, destination: writer, activeStorage: view.user.activeStorage, commit: op => op() }, + { maxItems: 2 } + ) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(3) + expect(await destination.knex('snapshot_sync_ids').where({ entity: 'txLabel' })).toHaveLength(3) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + child.kill('SIGKILL') + await exited + } + await view.close() + } + } +) + +test('source replacement fences old pages without replacing legacy checkpoint JSON', async () => { + const { source, destination } = await fixture(4) + await destination.findOrInsertSyncStateAuth( + { identityKey: identity, userId: (await destination.findUserByIdentityKey(identity))!.userId }, + 'source', + 'source' + ) + const legacyBytes = (await destination.knex('sync_states').first()).syncMap + const writer = destination.getSnapshotSync()! + const oldView = (await source.getSnapshotSync()!.openSource(identity))! + const old = await advance(oldView, writer, 3) + const stale = await writer.prepare(old, await oldView.readPage('txLabels')) + await oldView.close() + const fresh = (await source.getSnapshotSync()!.openSource(identity))! + const started = await writer.begin(fresh, fresh.user.activeStorage) + expect(started.sequence).toBe(0) + expect(started.sessionId).not.toBe(old.sessionId) + await expect(stale()).rejects.toThrow('session changed') + await runSnapshotSyncSession( + { view: fresh, destination: writer, activeStorage: fresh.user.activeStorage, commit: op => op() }, + {} + ) + expect((await destination.knex('sync_states').first()).syncMap).toBe(legacyBytes) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(4) + await fresh.close() +}) + +test('independent primary change rejects prepared data', async () => { + const { source, destination } = await fixture(1) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + const before = await advance(view, writer, 3) + const apply = await writer.prepare(before, await view.readPage('txLabels')) + await destination.knex('users').where({ identityKey: identity }).update({ activeStorage: 'another-primary' }) + await expect(apply()).rejects.toThrow('session changed') + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) + await view.close() +}) + +test('an independent away-and-back primary transition cannot revive an old session', async () => { + const { source, destination } = await fixture(1) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + const before = await advance(view, writer, 3) + const apply = await writer.prepare(before, await view.readPage('txLabels')) + await destination.knex('users').where({ identityKey: identity }).update({ activeStorage: 'another-primary' }) + await destination.knex('users').where({ identityKey: identity }).update({ activeStorage: 'source' }) + await expect(apply()).rejects.toThrow('session changed') + await expect(writer.begin(view, view.user.activeStorage)).rejects.toThrow('binding changed') + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) + await view.close() +}) + +test('cancellation during commit reports durable outcome and releases source capacity', async () => { + const { source, destination, manager } = await fixture(3) + const controller = new AbortController() + const capability = destination.getSnapshotSync()! + jest.spyOn(destination, 'getSnapshotSync').mockReturnValue({ + ...capability, + prepare: async (checkpoint, page) => { + const apply = await capability.prepare(checkpoint, page) + return async () => { + const result = await apply() + if (checkpoint.tableIndex === 3) controller.abort() + return result + } + } + }) + const result = await manager.syncToWriterResumable(await manager.getAuth(), destination, { + signal: controller.signal, + maxItems: 1 + }) + expect(result.status).toBe('cancelled') + expect(result.snapshotCheckpoint).toEqual(await capability.checkpoint(identity, 'source')) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(1) + const next = await source.getSnapshotSync()!.openSource(identity) + expect(next).toBeDefined() + await next!.close() +}) + +test('non-WAL SQLite preserves legacy serialized backup', async () => { + const { source, destination, manager } = await fixture(2, false) + expect(await source.getSnapshotSync()!.openSource(identity)).toBeUndefined() + const legacy = jest.spyOn(destination, 'processSyncChunk') + await manager.updateBackups() + expect(legacy).toHaveBeenCalled() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) + expect((await manager.syncToWriterResumable(await manager.getAuth(), destination)).mode).toBe('exclusive') +}) + +test('a backup borrowing an existing exclusive owner keeps the legacy reentrancy contract', async () => { + const { source, destination, manager } = await fixture(2) + const capability = jest.spyOn(source, 'getSnapshotSync') + const legacy = jest.spyOn(destination, 'processSyncChunk') + const log = await manager.runAsSync(active => manager.updateBackups(active)) + expect(log).toContain('BACKUP CURRENT ACTIVE TO 1 STORES') + expect(capability).not.toHaveBeenCalled() + expect(legacy).toHaveBeenCalled() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) +}) + +test.each([false, true])('primary replacement fences a pending legacy backup (switch back: %s)', async switchBack => { + const { source, destination, manager } = await fixture(2, false) + const entered = deferred() + const release = deferred() + const capability = source.getSnapshotSync()! + jest.spyOn(source, 'getSnapshotSync').mockReturnValue({ + ...capability, + openSource: async (...args) => { + entered.resolve() + await release.promise + return await capability.openSource(...args) + } + }) + const copying = observe(manager.updateBackups()) + let merge: jest.SpyInstance | undefined + let read: jest.SpyInstance | undefined + try { + await waitForBoundary(entered.promise, copying) + await manager.setActive('destination') + if (switchBack) await manager.setActive('source') + merge = jest.spyOn(destination, 'processSyncChunk') + read = jest.spyOn(source, 'getSyncChunk') + } finally { + release.resolve() + } + await expect(copying).rejects.toThrow('primary generation changed') + expect(read).not.toHaveBeenCalled() + expect(merge).not.toHaveBeenCalled() + expect(manager.getActiveStore()).toBe(switchBack ? 'source' : 'destination') +}) + +test('cancellation before source admission leaves durable session state untouched', async () => { + const { source, destination, manager } = await fixture(1) + const capability = source.getSnapshotSync()! + const opening = jest.fn(capability.openSource) + jest.spyOn(source, 'getSnapshotSync').mockReturnValue({ ...capability, openSource: opening }) + const controller = new AbortController() + controller.abort() + expect( + await manager.syncToWriterResumable(await manager.getAuth(), destination, { signal: controller.signal }) + ).toEqual({ + status: 'cancelled', + mode: 'paged', + pages: 0, + inserts: 0, + updates: 0 + }) + expect(opening).not.toHaveBeenCalled() + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) +}) + +test('destroy during the SQLite WAL probe rejects opening before creating a private reader', async () => { + const { source } = await fixture(0) + const entered = deferred() + const release = deferred() + jest.spyOn(source.knex.client, 'raw').mockImplementationOnce(() => { + entered.resolve() + return release.promise.then(() => [{ journal_mode: 'wal' }]) as never + }) + const privateReader = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot') + const opening = observe(source.getSnapshotSync()!.openSource(identity)) + let destroying: Promise | undefined + try { + await waitForBoundary(entered.promise, opening) + destroying = observe(source.destroy()) + } finally { + release.resolve() + } + await expect(opening).rejects.toThrow('destruction') + await destroying + expect(privateReader).not.toHaveBeenCalled() +}) + +test('snapshot pull preserves the active destination and remaps into an occupied profile', async () => { + const { source, destination } = await fixture(4) + const { user: foreign } = await destination.findOrInsertUser(foreignIdentity) + await destination.findOrInsertTxLabel(foreign.userId, 'foreign-existing') + const user = (await destination.findUserByIdentityKey(identity))! + await destination.updateUser(user.userId, { activeStorage: 'destination' }) + const manager = new WalletStorageManager(identity, destination) + await manager.makeAvailable() + const result = await manager.syncFromReaderResumable(identity, source, { maxItems: 2 }) + expect(result.snapshotCheckpoint?.done).toBe(true) + expect((await destination.findUserByIdentityKey(identity))!.activeStorage).toBe('destination') + expect(await destination.findTxLabels({ partial: { userId: user.userId } })).toHaveLength(4) + expect(await destination.findTxLabels({ partial: { userId: foreign.userId } })).toHaveLength(1) +}) + +async function seedClosure(source: StorageKnex, userId: number, otherId: number): Promise { + const date = when.toISOString() + const timestamp = { created_at: date, updated_at: date } + const k = source.knex + await k('output_baskets').del() + for (const id of [1, 2, 3]) { + await k('proven_txs').insert({ + ...timestamp, + provenTxId: id, + txid: String(id).repeat(64), + height: id, + index: 0, + merklePath: Buffer.from([id, 0, 255]), + rawTx: Buffer.from([id, 1, 255]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + await k('transactions').insert({ + ...timestamp, + transactionId: id, + userId: id === 2 ? otherId : userId, + provenTxId: id === 3 ? null : id, + status: 'completed', + reference: `tx-${id}`, + isOutgoing: true, + satoshis: 0, + description: `tx-${id}`, + txid: String(id).repeat(64), + rawTx: Buffer.from([id, 2, 255]), + inputBEEF: Buffer.from([id, 3, 255]) + }) + await k('proven_tx_reqs').insert({ + ...timestamp, + provenTxReqId: id, + provenTxId: id, + txid: String(id).repeat(64), + status: 'completed', + attempts: 0, + notified: true, + history: '{}', + notify: '{}', + rawTx: Buffer.from([id, 4, 255]), + wasBroadcast: true + }) + await k('output_baskets').insert({ + ...timestamp, + basketId: id, + userId: id === 2 ? otherId : userId, + name: `basket-${id}`, + isDeleted: id === 3 + }) + await k('outputs').insert({ + ...timestamp, + outputId: id, + userId: id === 2 ? otherId : userId, + transactionId: id, + basketId: id, + spendable: false, + change: true, + vout: 0, + satoshis: 1, + providedBy: 'you', + purpose: '', + type: 'P2PKH', + lockingScript: Buffer.from([id, 5, 255]) + }) + await k('commissions').insert({ + ...timestamp, + commissionId: id, + userId: id === 2 ? otherId : userId, + transactionId: id, + satoshis: 0, + keyOffset: 'offset', + isRedeemed: true, + lockingScript: Buffer.from([id, 6, 255]) + }) + await k('output_tags').insert({ + ...timestamp, + outputTagId: id, + userId: id === 2 ? otherId : userId, + tag: `tag-${id}`, + isDeleted: id === 3 + }) + await k('output_tags_map').insert({ ...timestamp, outputTagId: id, outputId: id, isDeleted: id === 3 }) + await k('tx_labels').insert({ + ...timestamp, + txLabelId: id, + userId: id === 2 ? otherId : userId, + label: `label-${id}`, + isDeleted: id === 3 + }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: id, transactionId: id, isDeleted: id === 3 }) + await k('certificates').insert({ + ...timestamp, + certificateId: id, + userId: id === 2 ? otherId : userId, + serialNumber: `serial-${id}`, + type: 'type', + certifier: identity, + subject: identity, + revocationOutpoint: 'a'.repeat(64) + '.0', + signature: 'signature', + isDeleted: id === 3 + }) + for (const fieldName of ['a', 'Z', 'é', '😀']) + await k('certificate_fields').insert({ + ...timestamp, + certificateId: id, + userId: id === 2 ? otherId : userId, + fieldName, + fieldValue: `value-${id}`, + masterKey: 'key' + }) + await k('sync_states').insert({ + ...timestamp, + syncStateId: id, + userId: id === 2 ? otherId : userId, + storageIdentityKey: `peer-${id}`, + storageName: `peer-${id}`, + status: 'unknown', + init: true, + refNum: `state-${id}`, + syncMap: '{}', + when: date + }) + } + // Composite positions must handle repeated first keys and preserve deleted mappings. + await k('output_tags_map').insert({ ...timestamp, outputTagId: 1, outputId: 3, isDeleted: true }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: 1, transactionId: 3, isDeleted: true }) +} + +test('all replica relations and packed blobs remap into an occupied destination, including request-only proofs', async () => { + const { source, destination, manager, user } = await fixture(0) + const foreign = (await source.findUserByIdentityKey(foreignIdentity))! + await seedClosure(source, user.userId, foreign.userId) + const { user: occupied } = await destination.findOrInsertUser(foreignIdentity) + await destination.insertTransaction({ + transactionId: 0, + userId: occupied.userId, + created_at: when, + updated_at: when, + status: 'completed', + reference: 'occupied', + isOutgoing: false, + satoshis: 0, + description: '', + txid: 'c'.repeat(64) + }) + await source + .knex('proven_tx_reqs') + .where({ provenTxReqId: 1 }) + .update({ notify: JSON.stringify({ transactionIds: [1, 2, 3] }) }) + const insertProof = jest.spyOn(destination, 'insertProvenTx') + await manager.updateBackups() + expect( + insertProof.mock.calls.every( + ([proof]) => proof.rawTx instanceof Uint8Array && proof.merklePath instanceof Uint8Array + ) + ).toBe(true) + const destinationUser = (await destination.findUserByIdentityKey(identity))! + const transactions = await destination.findTransactions({ partial: { userId: destinationUser.userId } }) + expect(transactions).toHaveLength(2) + expect(await destination.findProvenTxs({ partial: {} })).toHaveLength(2) + const outputs = await destination.findOutputs({ partial: { userId: destinationUser.userId } }) + expect(outputs).toHaveLength(2) + for (const output of outputs) { + expect(transactions.some(tx => tx.transactionId === output.transactionId)).toBe(true) + const basket = (await destination.findOutputBaskets({ partial: { basketId: output.basketId } }))[0] + expect(basket.userId).toBe(destinationUser.userId) + expect(basket.name).toBe(`basket-${output.lockingScript![0]}`) + expect(output.lockingScript).toEqual([output.lockingScript![0], 5, 255]) + } + expect(await destination.findCertificates({ partial: { userId: destinationUser.userId } })).toHaveLength(2) + expect(await destination.findCertificateFields({ partial: { userId: destinationUser.userId } })).toHaveLength(8) + expect(await destination.findCommissions({ partial: { userId: destinationUser.userId } })).toHaveLength(2) + const requests = await destination.findProvenTxReqs({ partial: {} }) + expect(requests).toHaveLength(2) + expect(JSON.parse(requests.find(row => row.txid === '1'.repeat(64))!.notify).transactionIds.sort()).toEqual( + transactions.map(tx => tx.transactionId).sort() + ) + expect(await destination.knex('sync_states')).toHaveLength(0) + const unchangedRequest = jest.spyOn(destination, 'updateProvenTxReq') + await manager.updateBackups() + expect(unchangedRequest).not.toHaveBeenCalled() + expect(await destination.findTransactions({ partial: { userId: destinationUser.userId } })).toHaveLength(2) + expect(await destination.findTransactions({ partial: { userId: occupied.userId } })).toHaveLength(1) +}) + +test.each(['batch', 'history', 'notify', 'updated_at'] as const)( + 'a changed proof-request %s persists once while an unchanged copy stays quiescent', + async field => { + const { source, destination, manager, user } = await fixture(0) + const foreign = (await source.findUserByIdentityKey(foreignIdentity))! + await seedClosure(source, user.userId, foreign.userId) + await manager.updateBackups() + const value = { + batch: 'batch-a', + history: JSON.stringify({ notes: [{ what: 'fixture', when: '2026-01-02T00:00:00.000Z' }] }), + notify: JSON.stringify({ transactionIds: [1, 2, 3] }), + updated_at: new Date('2026-01-02T00:00:00.000Z') + }[field] + // A replicated metadata change need not carry a newer timestamp. Exercise + // each merge reason independently instead of having updated_at mask it. + await source + .knex('proven_tx_reqs') + .where({ provenTxReqId: 1 }) + .update({ + [field]: value instanceof Date ? value.toISOString() : value + }) + const writes = jest.spyOn(destination, 'updateProvenTxReq') + expect(await manager.updateBackups()).toContain('0 inserts, 0 updates') + expect(writes).toHaveBeenCalledTimes(1) + const copied = (await destination.findProvenTxReqs({ partial: { txid: '1'.repeat(64) } }))[0] + if (field === 'notify') { + const transactions = await destination.findTransactions({ + partial: { userId: (await destination.findUserByIdentityKey(identity))!.userId } + }) + expect(JSON.parse(copied.notify).transactionIds).toEqual(transactions.map(row => row.transactionId).sort()) + expect(copied.notified).toBe(false) + } else expect(copied[field]).toEqual(value) + await destination.updateProvenTxReq(copied.provenTxReqId, { notified: true }) + writes.mockClear() + expect(await manager.updateBackups()).toContain('0 inserts, 0 updates') + expect(writes).not.toHaveBeenCalled() + expect((await destination.findProvenTxReqs({ partial: { provenTxReqId: copied.provenTxReqId } }))[0].notified).toBe( + true + ) + if (field === 'batch') { + await source.updateProvenTxReq(1, { batch: 'conflicting-batch' }) + await expect(manager.updateBackups()).rejects.toThrow('merge batch not equal') + expect((await destination.findProvenTxReqs({ partial: { provenTxReqId: copied.provenTxReqId } }))[0].batch).toBe( + 'batch-a' + ) + } + } +) + +test.each([false, true])('primary replacement fences a prepared snapshot page (switch back: %s)', async switchBack => { + const { destination, manager } = await fixture(3) + const gate = deferred() + const preparing = deferred() + const capability = destination.getSnapshotSync()! + const applyLatePage = jest.fn() + jest.spyOn(destination, 'getSnapshotSync').mockReturnValue({ + ...capability, + prepare: async (checkpoint, page) => { + const apply = await capability.prepare(checkpoint, page) + if (checkpoint.tableIndex === 3) { + applyLatePage.mockImplementation(apply) + preparing.resolve() + await gate.promise + return applyLatePage + } + return apply + } + }) + const copying = observe(manager.updateBackups()) + try { + await waitForBoundary(preparing.promise, copying) + await manager.setActive('destination') + if (switchBack) await manager.setActive('source') + } finally { + gate.resolve() + } + await expect(copying).rejects.toThrow('primary generation changed') + expect(applyLatePage).not.toHaveBeenCalled() + expect(manager.getActiveStore()).toBe(switchBack ? 'source' : 'destination') + expect((await destination.findUserByIdentityKey(identity))!.activeStorage).toBe(switchBack ? 'source' : 'destination') +}) + +test('cancellation while queued never begins the prepared destination page', async () => { + const { destination, manager } = await fixture(3) + const controller = new AbortController() + const entered = deferred() + const release = deferred() + let blocking: Promise | undefined + const copying = observe( + manager.syncToWriterResumable(await manager.getAuth(), destination, { + signal: controller.signal, + onProgress: progress => { + if (progress.state === 'committing' && progress.snapshotCheckpoint?.tableIndex === 3) { + blocking = observe( + manager.runAsWriter(async () => { + entered.resolve() + await release.promise + }) + ) + } + } + }) + ) + try { + await waitForBoundary(entered.promise, copying) + controller.abort() + } finally { + release.resolve() + } + await blocking + const result = await copying + expect(result.status).toBe('cancelled') + expect(result.snapshotCheckpoint?.tableIndex).toBe(3) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) +}) + +test('an uncommitted auxiliary migration keeps ordinary backup on the compatible legacy path', async () => { + const { destination, manager } = await fixture(2) + await destination.knex('knex_migrations').where({ name: '2026-09-30-001 add durable snapshot sync' }).del() + expect(await destination.getSnapshotSync()!.supportsDestination()).toBe(false) + const legacy = jest.spyOn(destination, 'processSyncChunk') + await manager.updateBackups() + expect(legacy).toHaveBeenCalled() + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) +}) + +test('large valid rows select serialized fallback after bounded commits without losing counts or data', async () => { + const { source, destination, manager, user } = await fixture(2) + const bytes = new Uint8Array(200000).fill(173) + await source.insertTransaction({ + transactionId: 0, + userId: user.userId, + created_at: when, + updated_at: when, + status: 'completed', + reference: 'large', + isOutgoing: false, + satoshis: 0, + description: '', + rawTx: bytes as unknown as number[] + }) + const legacy = jest.spyOn(destination, 'processSyncChunk') + const modes: string[] = [] + const result = await manager.syncToWriterResumable(await manager.getAuth(), destination, { + onProgress: p => modes.push(p.mode) + }) + expect(result.status).toBe('completed') + expect(result.mode).toBe('exclusive') + expect(result.inserts).toBe(3) + expect(modes).toContain('paged') + expect(modes).toContain('exclusive') + expect(legacy).toHaveBeenCalled() + const transactions = await destination.findTransactions({ partial: {} }) + expect(transactions).toHaveLength(1) + expect(new Uint8Array(transactions[0].rawTx!)).toEqual(bytes) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) + const fresh = await source.getSnapshotSync()!.openSource(identity) + await fresh!.close() +}) + +test.each(['admission', 'commit'] as const)( + 'expiry during destination %s selects fallback after physical cleanup', + async boundary => { + const { source, destination, manager } = await fixture(2) + const reader = source.getSnapshotSync()! + let view!: WalletReadSnapshot + jest.spyOn(source, 'getSnapshotSync').mockReturnValue({ + ...reader, + openSource: async (...args) => { + view = (await reader.openSource(...args))! + return view + } + }) + const writer = destination.getSnapshotSync()! + let expired = false + const expire = async (operation: () => Promise): Promise => { + expired = true + const clock = jest.spyOn(Date, 'now').mockReturnValue(view.expiresAt) + try { + return await operation() + } finally { + clock.mockRestore() + } + } + jest.spyOn(destination, 'getSnapshotSync').mockReturnValue({ + ...writer, + begin: async (...args) => + boundary === 'admission' && !expired ? await expire(() => writer.begin(...args)) : await writer.begin(...args), + prepare: async (checkpoint, page) => { + const apply = await writer.prepare(checkpoint, page) + return async () => + boundary === 'commit' && !expired && checkpoint.tableIndex === 3 ? await expire(apply) : await apply() + } + }) + const merge = destination.processSyncChunk.bind(destination) + const legacy = jest.spyOn(destination, 'processSyncChunk').mockImplementation(async (...args) => { + expect(view.isOpen).toBe(false) + await expect(view.closed).resolves.toBeUndefined() + return await merge(...args) + }) + const result = await manager.syncToWriterResumable(await manager.getAuth(), destination) + expect(result).toMatchObject({ status: 'completed', mode: 'exclusive', inserts: 2 }) + expect(legacy).toHaveBeenCalled() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) + const fresh = (await reader.openSource(identity))! + await fresh.close() + } +) + +test('profile or allocation corruption never selects the legacy fallback', async () => { + const { source, destination, manager } = await fixture(1) + const capability = destination.getSnapshotSync()! + const legacy = jest.spyOn(destination, 'processSyncChunk') + jest.spyOn(destination, 'getSnapshotSync').mockReturnValue({ + ...capability, + prepare: async (checkpoint, page) => { + if (checkpoint.tableIndex === 3) (page.rows[0] as { userId: number }).userId = 999 + return await capability.prepare(checkpoint, page) + } + }) + await expect(manager.updateBackups()).rejects.toThrow('another profile') + expect(legacy).not.toHaveBeenCalled() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) + const next = await source.getSnapshotSync()!.openSource(identity) + await next!.close() +}) + +test('rejects invalid source bindings before creating a destination session', async () => { + const { source, destination } = await fixture(1) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const capability = destination.getSnapshotSync()! + const invalid = [ + { ...view, version: 2 }, + { ...view, snapshotId: 'a'.repeat(63) }, + { ...view, snapshotId: 'g'.repeat(64) }, + { ...view, expiresAt: NaN }, + { ...view, expiresAt: Date.now() - 1 }, + { ...view, expiresAt: Date.now() + 36000000 }, + { ...view, user: { ...view.user, userId: 0 } }, + { ...view, user: { ...view.user, userId: 1.5 } }, + { ...view, user: { ...view.user, identityKey: 'not-an-identity' } }, + { ...view, sourceStorage: { ...view.sourceStorage, storageIdentityKey: '' } }, + { ...view, sourceStorage: { ...view.sourceStorage, storageIdentityKey: 'a'.repeat(131) } }, + { ...view, sourceStorage: { ...view.sourceStorage, storageIdentityKey: 'destination' } }, + { ...view, sourceStorage: { ...view.sourceStorage, chain: 'main' } } + ] + for (const header of invalid) + await expect(capability.begin(header as typeof view, view.user.activeStorage)).rejects.toThrow() + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) + await view.close() +}) + +test('rejects changed checkpoints and malformed pages without advancing durable progress', async () => { + const { source, destination } = await fixture(3) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const capability = destination.getSnapshotSync()! + const before = await advance(view, capability, 3) + const page = await view.readPage('txLabels', undefined, { maxRows: 1 }) + const changed = [ + { ...before, version: 2 }, + { ...before, sessionId: 'g'.repeat(64) }, + { ...before, sessionId: 'f'.repeat(64) }, + { ...before, snapshotId: 'f'.repeat(64) }, + { ...before, sequence: -1 }, + { ...before, sequence: 1.5 }, + { ...before, sequence: before.sequence + 1 }, + { ...before, tableIndex: -1 }, + { ...before, tableIndex: 0.5 }, + { ...before, tableIndex: 12, done: true }, + { ...before, destinationStorageIdentityKey: 'different' }, + { ...before, sourceStorageIdentityKey: 'different' }, + { ...before, identityKey: foreignIdentity } + ] + for (const checkpoint of changed) + await expect((async () => (await capability.prepare(checkpoint as typeof before, page))())()).rejects.toThrow() + const badPages = [ + { ...page, rows: [] }, + { ...page, rows: Array(1001).fill(page.rows[0]) }, + { ...page, done: undefined }, + { ...page, payloadBytes: -1 }, + { ...page, payloadBytes: Infinity }, + { ...page, payloadBytes: 16777217 }, + { ...page, payloadBytes: 1 }, + { ...page, cursor: undefined }, + { ...page, cursor: { ...page.cursor!, version: 2 } }, + { ...page, cursor: { ...page.cursor!, table: 'outputs' } }, + { ...page, cursor: { ...page.cursor!, after: [] } }, + { ...page, cursor: { ...page.cursor!, after: [999] } }, + { ...page, rows: [{ ...page.rows[0], userId: 999 }] }, + { ...page, rows: [{ ...page.rows[0], userId: undefined }] }, + { ...page, rows: [{ ...page.rows[0], txLabelId: 0 }], cursor: { ...page.cursor!, after: [0] } }, + { ...page, rows: [{ ...page.rows[0], txLabelId: NaN }] }, + { ...page, rows: [{ ...page.rows[0], label: { nested: true } }] }, + { ...page, rows: [{ ...page.rows[0], created_at: new Date(NaN) }] } + ] + for (const invalid of badPages) + await expect((async () => (await capability.prepare(before, invalid as typeof page))())()).rejects.toThrow() + expect(await capability.checkpoint(identity, 'source')).toEqual(before) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) + await view.close() +}) + +test('source admission, expiry and destruction retain bounded reader ownership', async () => { + const { source, destination } = await fixture(0) + const capability = source.getSnapshotSync()! + for (const lifetimeMs of [0, -1, 1.5, Infinity, 3600001]) + await expect(capability.openSource(identity, { lifetimeMs })).rejects.toThrow('lifetimeMs') + const controller = new AbortController() + controller.abort() + await expect(capability.openSource(identity, { signal: controller.signal })).rejects.toThrow('cancelled') + await expect(capability.openSource('02' + '33'.repeat(32))).rejects.toThrow('existing wallet profile') + const view = (await capability.openSource(identity, { lifetimeMs: 10000 }))! + await expect(capability.openSource(identity)).rejects.toThrow('already has') + expect(await destination.getSnapshotSync()!.checkpoint(identity, 'missing')).toBeUndefined() + expect(await destination.getSnapshotSync()!.checkpoint(foreignIdentity, 'source')).toBeUndefined() + const clock = jest.spyOn(Date, 'now').mockReturnValue(view.expiresAt) + await expect(view.readPage('txLabels')).rejects.toThrow('expired') + await expect(view.closed).resolves.toBeUndefined() + clock.mockRestore() + const fresh = (await capability.openSource(identity))! + await source.destroy() + await fresh.closed + expect(fresh.isOpen).toBe(false) + await expect(capability.openSource(identity)).rejects.toThrow('destruction') +}) + +test('all push and pull APIs reject another profile before touching the peer', async () => { + const { manager, destination } = await fixture(0) + const peer = jest.spyOn(destination, 'makeAvailable') + await expect(manager.syncToWriter({ identityKey: foreignIdentity }, destination)).rejects.toThrow() + await expect(manager.syncToWriterResumable({ identityKey: foreignIdentity }, destination)).rejects.toThrow() + await expect(manager.syncFromReader(foreignIdentity, destination)).rejects.toBeInstanceOf(WERR_UNAUTHORIZED) + await expect(manager.syncFromReaderResumable(foreignIdentity, destination)).rejects.toBeInstanceOf(WERR_UNAUTHORIZED) + expect(peer).not.toHaveBeenCalled() +}) + +test.each(['source', 'destination'] as const)( + 'disabling snapshot sync on the %s preserves ordinary backup through the legacy path', + async disabled => { + const { source, destination, manager } = await fixture(2, true, disabled) + const store = disabled === 'source' ? source : destination + const legacy = jest.spyOn(destination, 'processSyncChunk') + expect(store.getSnapshotSync()).toBeUndefined() + expect(await store.knex.schema.hasTable('snapshot_sync_sessions')).toBe(true) + expect(store.supportsWalletReadSnapshot()).toBe(true) + expect(await manager.updateBackups()).toContain('serialized complete') + expect(legacy).toHaveBeenCalled() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) + } +) + +test('profile selection and its update count are atomic with the first page', async () => { + const { source, destination, manager } = await fixture(0) + await destination + .knex('users') + .where({ identityKey: identity }) + .update({ activeStorage: 'old-primary', updated_at: '2000-01-01T00:00:00.000Z' }) + const controller = new AbortController() + const cancelled = await manager.syncToWriterResumable(await manager.getAuth(), destination, { + signal: controller.signal, + onProgress: progress => { + if (progress.state === 'reading') controller.abort() + } + }) + expect(cancelled.pages).toBe(0) + expect(cancelled.status).toBe('cancelled') + expect((await destination.findUserByIdentityKey(identity))!.activeStorage).toBe('old-primary') + const result = await manager.syncToWriterResumable(await manager.getAuth(), destination) + expect(result.status).toBe('completed') + expect(result.updates).toBe(1) + expect((await destination.findUserByIdentityKey(identity))!.activeStorage).toBe('source') + expect((await source.findUserByIdentityKey(identity))!.activeStorage).toBe('source') +}) + +test('ordinary copies retain their previous byte ceiling while a fresh manager initializes lazily', async () => { + const { source, destination } = await fixture(1) + const limits: number[] = [] + for (const store of [source, destination]) { + const capability = store.getSnapshotSync()! + jest.spyOn(store, 'getSnapshotSync').mockReturnValue({ + ...capability, + openSource: async (...args) => { + const view = (await capability.openSource(...args))! + return { + ...view, + get isOpen() { + return view.isOpen + }, + readPage: async (table, cursor, bounds) => { + limits.push(bounds!.maxBytes!) + return await view.readPage(table, cursor, bounds) + } + } + } + }) + } + const manager = new WalletStorageManager(identity, source, [destination]) + await manager.syncToWriter({ identityKey: identity }, destination) + await manager.updateBackups() + await manager.syncFromReader(identity, destination) + expect(limits.length).toBeGreaterThanOrEqual(36) + expect(limits.every(limit => limit === 10000000)).toBe(true) + const fresh = new WalletStorageManager(identity, source) + expect((await fresh.syncToWriterResumable({ identityKey: identity }, destination)).status).toBe('completed') +}) + +test('unmigrated destinations refuse durable sessions without applying an implicit migration', async () => { + const { source, destination } = await fixture(0) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const capability = destination.getSnapshotSync()! + await destination.knex.schema.dropTable('knex_migrations') + expect(await capability.supportsDestination()).toBe(false) + await expect(capability.begin(view, 'source')).rejects.toThrow('requires the version-one migration') + expect(await destination.knex.schema.hasTable('knex_migrations')).toBe(false) + await view.close() +}) + +test.each([-1, 9007199254740992])('invalid persisted primary epoch %s refuses a session', async epoch => { + const { source, destination } = await fixture(0) + const view = (await source.getSnapshotSync()!.openSource(identity))! + await destination.knex('snapshot_sync_primary_epochs').update({ epoch }) + await expect(destination.getSnapshotSync()!.begin(view, 'source')).rejects.toThrow('Invalid primary epoch') + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) + await view.close() +}) + +test('source expiry during destination admission creates no durable session', async () => { + const { source, destination } = await fixture(0) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const original = destination.findOrInsertUser.bind(destination) + let clock: jest.SpyInstance | undefined + jest.spyOn(destination, 'findOrInsertUser').mockImplementation(async (...args) => { + const result = await original(...args) + clock = jest.spyOn(Date, 'now').mockReturnValue(view.expiresAt) + return result + }) + try { + await expect(destination.getSnapshotSync()!.begin(view, 'source')).rejects.toThrow( + 'expired before session admission' + ) + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) + } finally { + clock?.mockRestore() + await view.close() + } +}) + +test.each(['changed', 'removed'] as const)( + 'a proof %s after preparation cannot advance a snapshot page', + async change => { + const { source, destination, user } = await fixture(0) + const foreign = (await source.findUserByIdentityKey(foreignIdentity))! + await seedClosure(source, user.userId, foreign.userId) + const proof = (await source.findProvenTxs({ partial: { provenTxId: 1 } }))[0] + await destination.insertProvenTx({ ...proof, provenTxId: 0 }) + const prior = (await destination.findProvenTxs({ partial: { txid: proof.txid } }))[0] + const second = (await source.findProvenTxs({ partial: { provenTxId: 3 } }))[0] + await destination.insertProvenTx({ ...second, provenTxId: 0 }) + const proofs = await destination.findProvenTxs({ partial: {} }) + const view = (await source.getSnapshotSync()!.openSource(identity))! + // Isolate the commit-time compare-and-set from the separately tested canonical + // proof service: this callback supplies its captured preflight authority. + const writer = new KnexSnapshotSyncDestination(destination, async () => new Map(proofs.map(row => [row.txid, row]))) + const before = await writer.begin(view, 'source') + const apply = await writer.prepare(before, await view.readPage('provenTxs')) + if (change === 'removed') await destination.knex('proven_txs').where({ provenTxId: prior.provenTxId }).del() + else await destination.updateProvenTx(prior.provenTxId, { blockHash: 'c'.repeat(64) }) + await expect(apply()).rejects.toThrow('Proof changed during snapshot preparation') + expect(await writer.checkpoint(identity, 'source')).toEqual(before) + expect(await destination.knex('snapshot_sync_ids')).toHaveLength(0) + await view.close() + } +) + +test('a source capability returning the wrong profile is closed before any destination mutation', async () => { + const { source, destination, manager } = await fixture(1) + const capability = source.getSnapshotSync()! + const writer = destination.getSnapshotSync()! + const begin = jest.fn(writer.begin) + let opened: WalletReadSnapshot | undefined + jest.spyOn(destination, 'getSnapshotSync').mockReturnValue({ ...writer, begin }) + jest.spyOn(source, 'getSnapshotSync').mockReturnValue({ + ...capability, + openSource: async (...args) => { + opened = (await capability.openSource(...args))! + return { ...opened, user: { ...opened.user, identityKey: foreignIdentity } } + } + }) + await expect(manager.syncToWriterResumable({ identityKey: identity }, destination)).rejects.toBeInstanceOf( + WERR_UNAUTHORIZED + ) + expect(begin).not.toHaveBeenCalled() + expect(opened!.isOpen).toBe(false) + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) +}) + +test('a cleanup failure remains observable after the destination commits the complete copy', async () => { + const { source, destination, manager } = await fixture(1) + const capability = source.getSnapshotSync()! + const failure = new Error('synthetic physical cleanup failure') + const legacy = jest.spyOn(destination, 'processSyncChunk') + jest.spyOn(source, 'getSnapshotSync').mockReturnValue({ + ...capability, + openSource: async (...args) => { + const view = (await capability.openSource(...args))! + return { + ...view, + close: async () => { + await view.close() + throw failure + } + } + } + }) + await expect(manager.syncToWriterResumable({ identityKey: identity }, destination)).rejects.toBe(failure) + expect((await destination.getSnapshotSync()!.checkpoint(identity, 'source'))!.done).toBe(true) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(1) + expect(legacy).not.toHaveBeenCalled() +}) + +test.each([ + ['sourceUserId', 999], + ['sourceActiveStorage', 'different-primary'], + ['sourceUserCreatedAt', '2000-01-01T00:00:00.000Z'], + ['sourceUserUpdatedAt', '2000-01-01T00:00:00.000Z'], + ['chain', 'main'], + ['destinationStorageIdentityKey', 'different-destination'], + ['version', 2], + ['activeStorage', 'different-primary'], + ['expiresAt', 1], + ['primaryEpoch', 99] +])('same-view admission refuses a changed durable %s binding', async (field, value) => { + const { source, destination } = await fixture(0) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + try { + await writer.begin(view, 'source') + await destination.knex('snapshot_sync_sessions').update({ [field]: value }) + const before = await destination.knex('snapshot_sync_sessions').first() + await expect(writer.begin(view, 'source')).rejects.toThrow('Snapshot session binding changed') + expect(await destination.knex('snapshot_sync_sessions').first()).toEqual(before) + } finally { + await view.close() + } +}) + +test.each([ + ['version', 2], + ['sessionId', 'g'.repeat(64)], + ['sessionId', 'x' + 'a'.repeat(64)], + ['sessionId', 'a'.repeat(64) + 'x'], + ['sequence', -1], + ['sequence', 0.5], + ['sequence', Number.MAX_SAFE_INTEGER + 1], + ['tableIndex', -1], + ['tableIndex', 0.5] +])('malformed checkpoint %s=%s rejects before proof preparation', async (field, value) => { + const { source, destination } = await fixture(0) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const proofs = jest.fn(async () => new Map()) + const writer = new KnexSnapshotSyncDestination(destination, proofs) + try { + const checkpoint = await writer.begin(view, 'source') + await expect( + writer.prepare({ ...checkpoint, [field]: value }, { rows: [], done: true, payloadBytes: 0 }) + ).rejects.toThrow('a version-one durable snapshot checkpoint') + expect(proofs).not.toHaveBeenCalled() + expect(await writer.checkpoint(identity, 'source')).toEqual(checkpoint) + } finally { + await view.close() + } +}) + +test.each(['snapshot-prefix', 'snapshot-suffix', 'identity-prefix', 'identity-suffix', 'storage-type'])( + 'source %s rejects before creating a profile or session', + async kind => { + const { source, destination } = await fixture(0) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + const input = { ...view, user: { ...view.user }, sourceStorage: { ...view.sourceStorage } } + if (kind === 'snapshot-prefix') input.snapshotId = 'x' + view.snapshotId + if (kind === 'snapshot-suffix') input.snapshotId = view.snapshotId + 'x' + if (kind === 'identity-prefix') input.user.identityKey = 'x' + identity + if (kind === 'identity-suffix') input.user.identityKey = identity + 'x' + if (kind === 'storage-type') input.sourceStorage.storageIdentityKey = 1 as unknown as string + try { + await expect(writer.begin(input, 'source')).rejects.toThrow('a live version-one wallet snapshot') + expect(await destination.findUsers({ partial: {} })).toHaveLength(1) + expect(await destination.knex('snapshot_sync_sessions')).toHaveLength(0) + } finally { + await view.close() + } + } +) + +test.each([ + ['version', 2], + ['identityKey', foreignIdentity], + ['snapshotId', 'c'.repeat(64)], + ['tableIndex', 2], + ['sequence', 99], + ['sessionId', 'c'.repeat(64)], + ['activeStorage', 'another-primary'], + ['chain', 'main'], + ['cursor-version', 2], + ['cursor-snapshotId', 'c'.repeat(64)], + ['cursor-table', 'outputs'], + ['cursor-after', [999]] +])('a prepared page refuses changed durable position %s before applying rows', async (field, value) => { + const { source, destination } = await fixture(3) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + try { + let checkpoint = await advance(view, writer, 3) + const first = await view.readPage('txLabels', checkpoint.cursor, { maxRows: 1 }) + checkpoint = (await (await writer.prepare(checkpoint, first))()).checkpoint + const page = await view.readPage('txLabels', checkpoint.cursor, { maxRows: 1 }) + const apply = await writer.prepare(checkpoint, page) + const patch = field.startsWith('cursor-') + ? { cursor: JSON.stringify({ ...checkpoint.cursor, [field.slice(7)]: value }) } + : { [field]: value } + await destination.knex('snapshot_sync_sessions').update(patch) + const prior = await destination.knex('snapshot_sync_sessions').first() + await expect(apply()).rejects.toThrow('Snapshot session changed') + expect(await destination.knex('snapshot_sync_sessions').first()).toEqual(prior) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(1) + } finally { + await view.close() + } +}) + +test('source admission accepts the exact lifetime and storage-identity length ceilings', async () => { + const { source, destination } = await fixture(0) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + const storageIdentityKey = 'a'.repeat(130) + try { + const result = await destination.getSnapshotSync()!.begin( + { + ...view, + expiresAt: now + 3600000, + sourceStorage: { ...view.sourceStorage, storageIdentityKey } + }, + 'source' + ) + expect(result.sourceStorageIdentityKey).toBe(storageIdentityKey) + expect(result.sequence).toBe(0) + } finally { + clock.mockRestore() + await view.close() + } +}) + +test.each(['push', 'backup', 'legacy-backup'] as const)( + 'ordinary %s delivers committed-page progress to the existing logger', + async operation => { + const { source, destination, manager } = await fixture(2, operation !== 'legacy-backup') + const messages: string[] = [] + const rendered: string[] = [] + const logger = (message: string): string => { + messages.push(message) + const value = `[observed:${message}]` + rendered.push(value) + return value + } + const log = + operation === 'push' + ? (await manager.syncToWriter(await manager.getAuth(), destination, undefined, 'prefix:', logger)).log + : await manager.updateBackups(undefined, logger) + const pages = messages.filter(message => message.startsWith('chunk ')) + expect(pages.length).toBeGreaterThan(0) + expect(messages.indexOf(pages[0])).toBeLessThan(messages.length - 1) + for (const value of rendered) expect(log).toContain(value) + if (operation === 'push') expect(log.startsWith('prefix:')).toBe(true) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) + expect(source.getSnapshotSync()).toBeDefined() + } +) + +test('snapshot capability configuration rejects a non-boolean before database admission', async () => { + const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + try { + expect( + () => + new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: database, + snapshotSync: 'enabled' as unknown as boolean + }) + ).toThrow('snapshotSync') + expect(database.client.pool.numUsed()).toBe(0) + } finally { + await database.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts new file mode 100644 index 000000000..0ef4009f8 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts @@ -0,0 +1,148 @@ +import fc from 'fast-check' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { snapshotSyncTables } from './SnapshotSync' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +test('random durable page schedules recover acknowledgements and restarts without profile leakage or duplicate rows', async () => { + const directory = await mkdtemp(join(tmpdir(), 'wallet-durable-property-')) + const open = (name: string) => + new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, name + '.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + }) + const source = open('source') + const destination = open('destination') + const identity = '02' + '11'.repeat(32) + const foreignIdentity = '03' + '22'.repeat(32) + try { + for (const [store, name] of [ + [source, 'source'], + [destination, 'destination'] + ] as const) { + await store.knex.raw('PRAGMA journal_mode = WAL') + await store.migrate(name, name) + await store.makeAvailable() + } + const { user } = await source.findOrInsertUser(identity) + const { user: foreign } = await source.findOrInsertUser(foreignIdentity) + const { user: occupied } = await destination.findOrInsertUser(foreignIdentity) + const { user: target } = await destination.findOrInsertUser(identity) + await source.updateUser(user.userId, { activeStorage: 'source' }) + await destination.updateUser(target.userId, { activeStorage: 'source' }) + const writer = destination.getSnapshotSync()! + await fc.assert( + fc.asyncProperty( + fc.array(fc.record({ foreign: fc.boolean(), deleted: fc.boolean(), length: fc.integer({ min: 0, max: 80 }) }), { + maxLength: 12 + }), + fc.integer({ min: 1, max: 6 }), + fc.integer({ min: 2048, max: 8192 }), + fc.array(fc.constantFrom('commit', 'lost-ack', 'stale', 'restart', 'discard'), { maxLength: 8 }), + async (entries, maxRows, maxBytes, steps) => { + await destination.knex('snapshot_sync_ids').del() + await destination.knex('snapshot_sync_sessions').del() + await destination.knex('tx_labels').del() + await source.knex('tx_labels').del() + const date = new Date('2026-01-01T00:00:00.000Z') + const records = entries.map((entry, index) => ({ + txLabelId: index + 1, + userId: entry.foreign ? foreign.userId : user.userId, + label: `label-${index}-${'é'.repeat(entry.length)}`, + isDeleted: entry.deleted, + created_at: date.toISOString(), + updated_at: date.toISOString() + })) + if (records.length > 0) await source.knex('tx_labels').insert(records) + await destination.findOrInsertTxLabel(occupied.userId, 'occupied') + let view = (await source.getSnapshotSync()!.openSource(identity))! + let checkpoint = await writer.begin(view, view.user.activeStorage) + let restarted = false + let step = 0 + const after = { + created_at: date.toISOString(), + updated_at: new Date(date.getTime() + 1).toISOString(), + userId: user.userId, + label: 'after-view', + isDeleted: true + } + await source.knex('tx_labels').insert(after) + try { + while (!checkpoint.done) { + expect(step).toBeLessThan(80) + const action = steps[step++] ?? 'commit' + const table = snapshotSyncTables[checkpoint.tableIndex] + const page = await view.readPage(table, checkpoint.cursor, { maxRows, maxBytes }) + expect(page.rows.length).toBeLessThanOrEqual(maxRows) + expect(page.payloadBytes).toBeLessThanOrEqual(maxBytes) + const apply = await writer.prepare(checkpoint, page) + if (action === 'restart' && !restarted) { + await view.close() + view = (await source.getSnapshotSync()!.openSource(identity))! + checkpoint = await writer.begin(view, view.user.activeStorage) + expect(checkpoint.sequence).toBe(0) + await expect(apply()).rejects.toThrow('session changed') + restarted = true + continue + } + if (action === 'discard') { + expect(await writer.checkpoint(identity, 'source')).toEqual(checkpoint) + continue + } + const stale = action === 'stale' ? await writer.prepare(checkpoint, page) : undefined + const acknowledged = await apply() + const durable = (await writer.checkpoint(identity, 'source'))! + expect(durable.sequence).toBe(checkpoint.sequence + 1) + expect(durable).toEqual(acknowledged.checkpoint) + if (stale !== undefined) await expect(stale()).rejects.toThrow('session changed') + // A discarded acknowledgement and an observed one converge on the + // same destination record, independent of a sender progress log. + checkpoint = action === 'lost-ack' ? durable : acknowledged.checkpoint + } + const project = (row: { label: string; isDeleted: boolean }) => ({ + label: row.label, + isDeleted: row.isDeleted + }) + const order = (a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label) + const expected = records.filter(row => row.userId === user.userId).map(project) + if (restarted) expected.push(project(after)) + const actual = await destination.findTxLabels({ partial: { userId: target.userId } }) + expect(actual.map(project).sort(order)).toEqual(expected.sort(order)) + expect(new Set(actual.map(row => row.txLabelId)).size).toBe(expected.length) + expect(await destination.findTxLabels({ partial: { userId: occupied.userId } })).toHaveLength(1) + const mappings = await destination.knex('snapshot_sync_ids').where({ entity: 'txLabel' }) + expect(mappings).toHaveLength(expected.length) + expect( + mappings.every(row => row.userId === target.userId && row.sourceStorageIdentityKey === 'source') + ).toBe(true) + expect(await destination.knex('sync_states')).toHaveLength(0) + } finally { + await view.close() + } + } + ) + ) + } finally { + await source.destroy() + await destination.destroy() + await rm(directory, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.ts new file mode 100644 index 000000000..4e68ad389 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.ts @@ -0,0 +1,70 @@ +import type { TableSettings, TableUser } from '../schema/tables' +import type { + WalletReadSnapshot, + WalletReadSnapshotOptions, + WalletSnapshotCursor, + WalletSnapshotPage +} from './WalletReadSnapshot' + +/** Dependency order for replica merges. Source operational sync states are archive provenance, not replica cursors. */ +export const snapshotSyncTables = Object.freeze([ + 'provenTxs', + 'outputBaskets', + 'outputTags', + 'txLabels', + 'transactions', + 'outputs', + 'txLabelMaps', + 'outputTagMaps', + 'certificates', + 'certificateFields', + 'commissions', + 'provenTxReqs' +] as const) +export type SnapshotSyncTable = (typeof snapshotSyncTables)[number] + +/** Version one binds the packed row schema as well as the source view and profile. Local only. */ +export interface SnapshotSyncSource { + version: 1 + snapshotId: string + sourceStorage: TableSettings + user: TableUser + expiresAt: number +} + +export interface SnapshotSyncCheckpoint { + version: 1 + sessionId: string + identityKey: string + sourceStorageIdentityKey: string + destinationStorageIdentityKey: string + snapshotId: string + /** Next table in snapshotSyncTables. Its length means complete. */ + tableIndex: number + sequence: number + cursor?: WalletSnapshotCursor + done: boolean +} + +export interface SnapshotSyncCommit { + checkpoint: SnapshotSyncCheckpoint + inserts: number + updates: number +} + +/** Additive local provider capability. These methods are deliberately absent from the RPC allowlist. */ +export interface SnapshotSyncStorage { + /** True only after the version-one auxiliary schema and primary fencing migration committed. */ + supportsDestination: () => Promise + /** Undefined means this configuration cannot retain a view while foreground writes proceed. */ + openSource: (identityKey: string, options?: WalletReadSnapshotOptions) => Promise + /** Same-view retry resumes the destination cursor. A different view atomically fences its predecessor and starts at table zero. */ + begin: (source: SnapshotSyncSource, activeStorage: string | undefined) => Promise + /** Observe the durable outcome after a lost acknowledgement. Never infer progress from a sender log. */ + checkpoint: (identityKey: string, sourceStorageIdentityKey: string) => Promise + /** Detaches input and completes proof I/O before returning a one-use atomic merge/checkpoint operation. */ + prepare: ( + checkpoint: SnapshotSyncCheckpoint, + page: WalletSnapshotPage + ) => Promise<() => Promise> +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.test.ts new file mode 100644 index 000000000..586ac07a4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.test.ts @@ -0,0 +1,261 @@ +import { knex, type Knex } from 'knex' +import { loadSnapshotIdMap, detachSnapshotSyncPage } from './SnapshotSyncRows' +import type { SyncChunk } from '../../sdk/WalletStorage.interfaces' +import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' +import type { SnapshotSyncCheckpoint, SnapshotSyncTable } from './SnapshotSync' +import type { WalletSnapshotPage } from './WalletReadSnapshot' + +let database: Knex +const scope = { userId: 10, sourceStorageIdentityKey: 'source' } +beforeEach(async () => { + database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + await database.schema.createTable('snapshot_sync_ids', table => { + table.integer('userId') + table.string('sourceStorageIdentityKey') + table.string('entity') + table.integer('incomingId') + table.integer('localId') + table.primary(['userId', 'sourceStorageIdentityKey', 'entity', 'incomingId']) + }) +}) +afterEach(async () => { + await database.destroy() +}) +function chunk(table: SnapshotSyncTable, rows: unknown[]): SyncChunk { + return { + userIdentityKey: 'identity', + fromStorageIdentityKey: 'source', + toStorageIdentityKey: 'destination', + [table]: rows + } as SyncChunk +} + +test('ID lookup is profile/source scoped, maps parents and persists only newly learned IDs', async () => { + await database('snapshot_sync_ids').insert([ + { ...scope, entity: 'transaction', incomingId: 1, localId: 101 }, + { ...scope, entity: 'outputBasket', incomingId: 2, localId: 102 }, + { ...scope, entity: 'output', incomingId: 3, localId: 103 }, + { ...scope, userId: 20, entity: 'transaction', incomingId: 1, localId: 999 }, + { ...scope, sourceStorageIdentityKey: 'other', entity: 'transaction', incomingId: 1, localId: 888 } + ]) + const loaded = await loadSnapshotIdMap( + database, + scope, + 7, + 'outputs', + chunk('outputs', [ + { outputId: 3, userId: 7, transactionId: 1, basketId: 2, spentBy: 1 }, + { outputId: 4, userId: 7, transactionId: 1, basketId: 2 } + ]) + ) + expect(loaded.map.transaction.idMap).toEqual({ 1: 101 }) + expect(loaded.map.outputBasket.idMap).toEqual({ 2: 102 }) + expect(loaded.map.output.idMap).toEqual({ 3: 103 }) + loaded.map.output.idMap[4] = 104 + await loaded.persist() + expect( + await database('snapshot_sync_ids') + .where({ ...scope, entity: 'output' }) + .orderBy('incomingId') + ).toEqual([ + { ...scope, entity: 'output', incomingId: 3, localId: 103 }, + { ...scope, entity: 'output', incomingId: 4, localId: 104 } + ]) + expect(await database('snapshot_sync_ids')).toHaveLength(6) +}) + +test('mapping references are capped before SQL, while the exact bound permits bounded lookup batches', async () => { + const notify = (count: number) => + chunk('provenTxReqs', [ + { provenTxReqId: 1, notify: JSON.stringify({ transactionIds: Array.from({ length: count }, (_, n) => n + 1) }) } + ]) + const queries: Array<{ sql: string; bindings: unknown[] }> = [] + database.on('query', query => queries.push(query)) + await expect(loadSnapshotIdMap(database, scope, 7, 'provenTxReqs', notify(4096))).rejects.toBeInstanceOf( + SnapshotResourceLimitError + ) + expect(queries).toHaveLength(0) + const accepted = await loadSnapshotIdMap(database, scope, 7, 'provenTxReqs', notify(4095)) + expect(queries).toHaveLength(33) + expect(queries.every(query => query.bindings.length <= 131)).toBe(true) + // Unmapped notifications can refer to another profile and are not fabricated. + expect(accepted.map.transaction.idMap).toEqual({}) + await accepted.persist() + expect(queries).toHaveLength(33) +}) + +test('missing parent mappings and invalid notification IDs cannot produce a partial map', async () => { + await expect( + loadSnapshotIdMap( + database, + scope, + 7, + 'transactions', + chunk('transactions', [{ userId: 7, transactionId: 1, provenTxId: 2 }]) + ) + ).rejects.toThrow('parent mapping is missing') + for (const transactionIds of [1, {}, [0], [-1], [1.5], ['1']]) + await expect( + loadSnapshotIdMap( + database, + scope, + 7, + 'provenTxReqs', + chunk('provenTxReqs', [{ provenTxReqId: 1, notify: JSON.stringify({ transactionIds }) }]) + ) + ).rejects.toThrow() + expect(await database('snapshot_sync_ids')).toHaveLength(0) +}) + +test('packed row admission refuses non-record rows and extra allocation before copying', () => { + const checkpoint: SnapshotSyncCheckpoint = { + version: 1, + sessionId: 'a'.repeat(64), + snapshotId: 'b'.repeat(64), + identityKey: 'identity', + sourceStorageIdentityKey: 'source', + destinationStorageIdentityKey: 'destination', + tableIndex: 3, + sequence: 3, + done: false + } + const page = (row: unknown): WalletSnapshotPage<'txLabels'> => + ({ + rows: [row], + payloadBytes: 8192, + done: true, + cursor: { version: 1, snapshotId: checkpoint.snapshotId, table: 'txLabels', after: [1] } + }) as WalletSnapshotPage<'txLabels'> + for (const row of [null, 1, Object.fromEntries(Array.from({ length: 65 }, (_, n) => ['column' + n, n]))]) + expect(() => detachSnapshotSyncPage(checkpoint, page(row))).toThrow('flat snapshot records') + expect(() => + detachSnapshotSyncPage(checkpoint, { + rows: [], + payloadBytes: 0, + done: true, + cursor: { version: 1, snapshotId: checkpoint.snapshotId, table: 'txLabels', after: [1] } + }) + ).toThrow('unchanged empty-page') + const prior = { version: 1 as const, snapshotId: checkpoint.snapshotId, table: 'txLabels' as const, after: [1] } + expect( + detachSnapshotSyncPage({ ...checkpoint, cursor: prior }, { rows: [], payloadBytes: 0, done: true, cursor: prior }) + ).toMatchObject({ nextTable: 4, nextCursor: null }) + expect(() => detachSnapshotSyncPage({ ...checkpoint, cursor: prior }, page({ txLabelId: 1 }))).toThrow('last row') +}) + +const boundedCheckpoint: SnapshotSyncCheckpoint = { + version: 1, + sessionId: 'a'.repeat(64), + snapshotId: 'b'.repeat(64), + identityKey: 'identity', + sourceStorageIdentityKey: 'source', + destinationStorageIdentityKey: 'destination', + tableIndex: 3, + sequence: 3, + done: false +} +function boundedPage(rows: unknown[], payloadBytes = 16777216): WalletSnapshotPage<'txLabels'> { + const last = rows.at(-1) as { txLabelId: number } | undefined + return { + rows, + done: true, + payloadBytes, + ...(last === undefined + ? {} + : { + cursor: { version: 1, snapshotId: boundedCheckpoint.snapshotId, table: 'txLabels', after: [last.txLabelId] } + }) + } as WalletSnapshotPage<'txLabels'> +} + +test.each([ + [NaN, 'finite numbers'], + [Infinity, 'finite numbers'], + [new Date(NaN), 'finite dates'], + [{ nested: true }, 'packed bytes, dates and scalar values'], + [[1, 2], 'packed bytes, dates and scalar values'] +])('allocation refuses unsupported value %p before cloning', (value, reason) => { + expect(() => detachSnapshotSyncPage(boundedCheckpoint, boundedPage([{ txLabelId: 1, value }]))).toThrow( + `The rows parameter must be ${reason}` + ) +}) + +test.each(['abc', new Uint8Array([1, 2, 3])])( + 'allocation charge covers each string or packed-byte payload %p', + value => { + // Two cells cost 128 bytes plus twice the three-unit payload. + const page = boundedPage([{ txLabelId: 1, value }], 134) + expect(detachSnapshotSyncPage(boundedCheckpoint, page).chunk.txLabels).toHaveLength(1) + expect(() => detachSnapshotSyncPage(boundedCheckpoint, { ...page, payloadBytes: 133 })).toThrow( + 'The payloadBytes parameter must be an allocation charge covering every row' + ) + } +) + +test('inclusive row, column and byte ceilings accept their exact bounds', () => { + const row = Object.fromEntries(Array.from({ length: 63 }, (_, n) => ['column' + n, n])) + expect( + detachSnapshotSyncPage(boundedCheckpoint, boundedPage([{ ...row, txLabelId: 1 }], 4096)).chunk.txLabels + ).toHaveLength(1) + const page = boundedPage(Array.from({ length: 1000 }, (_, n) => ({ txLabelId: n + 1 }))) + expect(detachSnapshotSyncPage(boundedCheckpoint, page).chunk.txLabels).toHaveLength(1000) +}) + +test.each([ + { rows: undefined }, + { rows: Array.from({ length: 1001 }, (_, n) => ({ txLabelId: n + 1 })) }, + { done: undefined }, + { payloadBytes: -1 }, + { payloadBytes: 0.5 }, + { payloadBytes: 16777217 }, + { rows: [], done: false } +])('invalid page envelope %p rejects at page admission', patch => { + expect(() => + detachSnapshotSyncPage(boundedCheckpoint, { ...boundedPage([]), ...patch } as WalletSnapshotPage<'txLabels'>) + ).toThrow('The page parameter must be a bounded snapshot page making progress') +}) + +test.each([{ tableIndex: 12 }, { done: true }])('complete checkpoints refuse pages before allocation: %p', patch => { + expect(() => detachSnapshotSyncPage({ ...boundedCheckpoint, ...patch }, boundedPage([]))).toThrow( + 'Snapshot sync is already complete' + ) +}) + +test('composite cursors must match every final-row key and the original source view', () => { + const checkpoint = { ...boundedCheckpoint, tableIndex: 6 } + const page: WalletSnapshotPage<'txLabelMaps'> = { + rows: [{ txLabelId: 1, transactionId: 2 } as never], + done: false, + payloadBytes: 128, + cursor: { version: 1, snapshotId: checkpoint.snapshotId, table: 'txLabelMaps', after: [1, 2] } + } + expect(detachSnapshotSyncPage(checkpoint, page).nextCursor).toBe(JSON.stringify(page.cursor)) + for (const after of [ + [1, 3], + [3, 2] + ]) + expect(() => detachSnapshotSyncPage(checkpoint, { ...page, cursor: { ...page.cursor!, after } })).toThrow( + 'The page.cursor parameter must be the last row of this source view and table' + ) + for (const cursor of [undefined, { ...page.cursor!, snapshotId: 'c'.repeat(64) }]) + expect(() => detachSnapshotSyncPage(checkpoint, { ...page, cursor })).toThrow( + 'The page.cursor parameter must be the last row of this source view and table' + ) +}) + +test('mapping batches never exceed 128 new IDs or include another profile in a global row', async () => { + const queries: Array<{ sql: string; bindings: unknown[] }> = [] + database.on('query', query => queries.push(query)) + const rows = Array.from({ length: 256 }, (_, n) => ({ userId: 7, txLabelId: n + 1 })) + const loaded = await loadSnapshotIdMap(database, scope, 7, 'txLabels', chunk('txLabels', rows)) + expect(queries).toHaveLength(2) + for (const row of rows) loaded.map.txLabel.idMap[row.txLabelId] = row.txLabelId + 1000 + await loaded.persist() + const inserts = queries.filter(query => query.sql.startsWith('insert')) + expect(inserts).toHaveLength(2) + expect(inserts.every(query => query.bindings.length === 128 * 5)).toBe(true) + expect(await database('snapshot_sync_ids')).toHaveLength(256) + await expect( + loadSnapshotIdMap(database, scope, 7, 'provenTxs', chunk('provenTxs', [{ provenTxId: 1, userId: 99 }])) + ).rejects.toThrow('Snapshot row belongs to another profile') +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts new file mode 100644 index 000000000..eed27b31d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts @@ -0,0 +1,244 @@ +import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' +import type { Knex } from 'knex' +import type { SyncChunk } from '../../sdk/WalletStorage.interfaces' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' +import { createSyncMap, type SyncMap } from '../schema/entities/EntityBase' +import { snapshotSyncPage } from '../sync/snapshotSyncPage' +import { snapshotSyncTables, type SnapshotSyncCheckpoint, type SnapshotSyncTable } from './SnapshotSync' +import type { WalletSnapshotPage } from './WalletReadSnapshot' + +const entities: Record = { + provenTxs: 'provenTx', + outputBaskets: 'outputBasket', + outputTags: 'outputTag', + txLabels: 'txLabel', + transactions: 'transaction', + outputs: 'output', + txLabelMaps: 'txLabelMap', + outputTagMaps: 'outputTagMap', + certificates: 'certificate', + certificateFields: 'certificateField', + commissions: 'commission', + provenTxReqs: 'provenTxReq' +} +const ids: Record = { + provenTxId: 'provenTx', + basketId: 'outputBasket', + outputTagId: 'outputTag', + txLabelId: 'txLabel', + transactionId: 'transaction', + spentBy: 'transaction', + outputId: 'output', + certificateId: 'certificate', + commissionId: 'commission', + provenTxReqId: 'provenTxReq' +} +const keys: Record = { + provenTxs: ['provenTxId'], + outputBaskets: ['basketId'], + outputTags: ['outputTagId'], + txLabels: ['txLabelId'], + transactions: ['transactionId'], + outputs: ['outputId'], + txLabelMaps: ['txLabelId', 'transactionId'], + outputTagMaps: ['outputTagId', 'outputId'], + certificates: ['certificateId'], + certificateFields: ['fieldName', 'certificateId'], + commissions: ['commissionId'], + provenTxReqs: ['provenTxReqId'] +} + +function allocationCharge(value: unknown): number { + if (typeof value === 'string') return 64 + value.length * 2 + if (value instanceof Uint8Array) return 64 + value.byteLength * 2 + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new WERR_INVALID_PARAMETER('rows', 'finite numbers') + } else if (value != null && typeof value !== 'boolean') { + let time: number + try { + time = Date.prototype.getTime.call(value) + } catch { + throw new WERR_INVALID_PARAMETER('rows', 'packed bytes, dates and scalar values') + } + if (!Number.isFinite(time)) throw new WERR_INVALID_PARAMETER('rows', 'finite dates') + } + return 64 +} + +/** Recheck packed allocation before copying even when called without the built-in SQL reader. */ +function validateAllocation(rows: unknown[], advertisedBytes: number): void { + let charge = 0 + for (const row of rows) { + if (row === null || typeof row !== 'object' || Object.keys(row).length > 64) { + throw new WERR_INVALID_PARAMETER('rows', 'flat snapshot records with at most 64 columns') + } + for (const value of Object.values(row)) { + charge += allocationCharge(value) + if (charge > advertisedBytes) + throw new WERR_INVALID_PARAMETER('payloadBytes', 'an allocation charge covering every row') + } + } +} + +export function detachSnapshotSyncPage( + checkpoint: SnapshotSyncCheckpoint, + page: WalletSnapshotPage +): { + chunk: SyncChunk + nextCursor: string | null + nextTable: number +} { + const table = snapshotSyncTables[checkpoint.tableIndex] + if (table === undefined || checkpoint.done) throw new WERR_INVALID_OPERATION('Snapshot sync is already complete') + if ( + !Array.isArray(page.rows) || + page.rows.length > 1000 || + typeof page.done !== 'boolean' || + !Number.isSafeInteger(page.payloadBytes) || + page.payloadBytes < 0 || + page.payloadBytes > 16777216 || + (!page.done && page.rows.length === 0) + ) { + throw new WERR_INVALID_PARAMETER('page', 'a bounded snapshot page making progress') + } + validateAllocation(page.rows, page.payloadBytes) + const cursor = page.cursor + if (page.rows.length > 0) { + const last = page.rows.at(-1) as unknown as Record + if ( + cursor?.version !== 1 || + cursor.snapshotId !== checkpoint.snapshotId || + cursor.table !== table || + !Array.isArray(cursor.after) || + cursor.after.length !== keys[table].length || + cursor.after.some((value, index) => value !== last[keys[table][index]]) || + JSON.stringify(cursor) === JSON.stringify(checkpoint.cursor) + ) { + throw new WERR_INVALID_PARAMETER('page.cursor', 'the last row of this source view and table') + } + } else if (cursor !== undefined && JSON.stringify(cursor) !== JSON.stringify(checkpoint.cursor)) { + throw new WERR_INVALID_PARAMETER('page.cursor', 'the unchanged empty-page position') + } + // Entity merges and SQL serializers accept typed byte buffers. Keep the legacy + // public SyncChunk number[] declaration unchanged at this internal boundary. + const chunk = snapshotSyncPage( + { + identityKey: checkpoint.identityKey, + fromStorageIdentityKey: checkpoint.sourceStorageIdentityKey, + toStorageIdentityKey: checkpoint.destinationStorageIdentityKey, + offsets: [], + maxItems: 1000, + maxRoughSize: 16777216 + }, + { + userIdentityKey: checkpoint.identityKey, + fromStorageIdentityKey: checkpoint.sourceStorageIdentityKey, + toStorageIdentityKey: checkpoint.destinationStorageIdentityKey, + [table]: page.rows + } as SyncChunk + ).chunk + return { + chunk, + nextCursor: page.done ? null : JSON.stringify(cursor), + nextTable: checkpoint.tableIndex + Number(page.done) + } +} + +interface IdRow { + entity: keyof SyncMap + incomingId: number + localId: number +} +export interface SnapshotMapScope { + userId: number + sourceStorageIdentityKey: string +} + +interface PageReferences { + wanted: Map> + required: Map> + count: number +} + +function addReference(refs: PageReferences, entity: keyof SyncMap, id: unknown, parent: boolean): void { + if (!Number.isSafeInteger(id) || (id as number) < 1) throw new WERR_INVALID_PARAMETER('row', 'positive safe IDs') + const set = refs.wanted.get(entity) ?? new Set() + if (!set.has(id as number) && ++refs.count > 4096) + throw new SnapshotResourceLimitError('Snapshot page exceeds 4096 distinct ID references') + set.add(id as number) + refs.wanted.set(entity, set) + if (parent) { + const parents = refs.required.get(entity) ?? new Set() + parents.add(id as number) + refs.required.set(entity, parents) + } +} + +function collectNotifications(refs: PageReferences, row: Record): void { + const notify = JSON.parse(row.notify as string) as { transactionIds?: unknown[] } + if (notify.transactionIds == null) return + if (!Array.isArray(notify.transactionIds)) throw new WERR_INVALID_PARAMETER('notify', 'an array of transaction IDs') + // Global request notification lists may also contain other profiles. Only + // mappings established for this profile may survive the existing merge. + for (const id of notify.transactionIds) addReference(refs, 'transaction', id, false) +} + +function collectReferences(sourceUserId: number, table: SnapshotSyncTable, chunk: SyncChunk): PageReferences { + const refs: PageReferences = { wanted: new Map(), required: new Map(), count: 0 } + const rows = chunk[table] as unknown as Array> + const owned = !['provenTxs', 'provenTxReqs', 'txLabelMaps', 'outputTagMaps'].includes(table) + for (const row of rows) { + if ((owned || row.userId !== undefined) && row.userId !== sourceUserId) + throw new WERR_INVALID_OPERATION('Snapshot row belongs to another profile') + for (const [field, entity] of Object.entries(ids)) { + if (row[field] != null) addReference(refs, entity, row[field], entity !== entities[table]) + } + if (table === 'provenTxReqs') collectNotifications(refs, row) + } + return refs +} + +/** Load only incoming IDs and their parent references; never parse or overwrite the legacy JSON map. */ +export async function loadSnapshotIdMap( + k: Knex, + scope: SnapshotMapScope, + sourceUserId: number, + table: SnapshotSyncTable, + chunk: SyncChunk +): Promise<{ map: SyncMap; persist: () => Promise }> { + const { wanted, required } = collectReferences(sourceUserId, table, chunk) + const map = createSyncMap() + const known = new Map>() + for (const [entity, values] of wanted) { + const list = [...values] + for (let offset = 0; offset < list.length; offset += 128) { + const found: IdRow[] = await k('snapshot_sync_ids') + .select('incomingId', 'localId') + .where({ ...scope, entity }) + .whereIn('incomingId', list.slice(offset, offset + 128)) + for (const row of found) map[entity].idMap[row.incomingId] = row.localId + } + known.set(entity, new Set(Object.keys(map[entity].idMap).map(Number))) + } + for (const [entity, values] of required) { + for (const id of values) { + if (map[entity].idMap[id] === undefined) + throw new WERR_INVALID_OPERATION('Snapshot parent mapping is missing; restart from the durable source view') + } + } + return { + map, + async persist() { + const additions: Array = [] + for (const [entity, values] of wanted) { + for (const id of values) { + const localId = map[entity].idMap[id] + if (localId !== undefined && !known.get(entity)?.has(id)) + additions.push({ ...scope, entity, incomingId: id, localId }) + } + } + for (let offset = 0; offset < additions.length; offset += 128) + await k('snapshot_sync_ids').insert(additions.slice(offset, offset + 128)) + } + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts new file mode 100644 index 000000000..c3a991184 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts @@ -0,0 +1,327 @@ +import { runSnapshotSyncSession } from './runSnapshotSyncSession' +import type { WalletReadSnapshot } from './WalletReadSnapshot' +import type { SnapshotSyncCheckpoint, SnapshotSyncCommit, SnapshotSyncStorage } from './SnapshotSync' +import type { SyncSessionOptions, SyncSessionProgress } from '../sync/syncSession' +import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' +import { SyncPageBudget } from '../sync/SyncPageBudget' + +function session() { + const checkpoint: SnapshotSyncCheckpoint = { + version: 1, + sessionId: 'a'.repeat(64), + snapshotId: 'b'.repeat(64), + identityKey: 'identity', + sourceStorageIdentityKey: 'source', + destinationStorageIdentityKey: 'destination', + tableIndex: 11, + sequence: 11, + done: false, + cursor: { version: 1, snapshotId: 'b'.repeat(64), table: 'provenTxReqs', after: [7] } + } + const view = { + isOpen: true, + expiresAt: Date.now() + 10000, + closed: Promise.resolve(), + readPage: jest.fn(async () => ({ rows: [], payloadBytes: 0, done: true })) + } as unknown as WalletReadSnapshot + const apply = jest.fn(async (): Promise => ({ + checkpoint: { ...checkpoint, cursor: undefined, sequence: 12, tableIndex: 12, done: true }, + inserts: 2, + updates: 3 + })) + const destination = { + begin: jest.fn(async () => checkpoint), + prepare: jest.fn(async () => apply) + } as unknown as SnapshotSyncStorage + const commit = jest.fn(async (work: () => Promise) => await work()) + return { input: { view, destination, commit, activeStorage: 'source' }, checkpoint, view, destination, apply, commit } +} + +test.each([ + { maxItems: 0 }, + { maxItems: -1 }, + { maxItems: 1.5 }, + { maxItems: 1001 }, + { maxItems: NaN }, + { maxRoughSize: 0 }, + { maxRoughSize: -1 }, + { maxRoughSize: 1.5 }, + { maxRoughSize: 10000001 }, + { maxRoughSize: Infinity } +])('rejects invalid limits before opening destination state: %j', async options => { + const f = session() + await expect(runSnapshotSyncSession(f.input, options)).rejects.toThrow('limits') + expect(f.commit).not.toHaveBeenCalled() +}) + +test('progress uses detached checkpoints and reports the committed counts and timings', async () => { + const f = session() + const states: SyncSessionProgress['state'][] = [] + const result = await runSnapshotSyncSession(f.input, { + maxItems: 17, + maxRoughSize: 2048, + onProgress: progress => { + states.push(progress.state) + if (progress.snapshotCheckpoint?.cursor) progress.snapshotCheckpoint.cursor.after[0] = 999 + if (progress.snapshotCheckpoint) progress.snapshotCheckpoint.identityKey = 'changed-by-listener' + if (progress.state === 'committed') { + expect(progress).toMatchObject({ pages: 1, inserts: 2, updates: 3 }) + for (const value of [progress.readMs, progress.prepareMs, progress.commitMs, progress.queueMs]) + expect(value).toBeGreaterThanOrEqual(0) + } + } + }) + expect(states).toEqual(['reading', 'preparing', 'committing', 'committed', 'completed']) + expect(f.view.readPage).toHaveBeenCalledWith('provenTxReqs', f.checkpoint.cursor, { + maxRows: expect.any(Number), + maxBytes: 2048 + }) + const limits = (f.view.readPage as jest.Mock).mock.calls[0][2] + expect(limits.maxRows).toBeGreaterThanOrEqual(1) + expect(limits.maxRows).toBeLessThanOrEqual(17) + expect(f.checkpoint.cursor!.after).toEqual([7]) + expect(result).toMatchObject({ + status: 'completed', + mode: 'paged', + pages: 1, + inserts: 2, + updates: 3, + snapshotCheckpoint: { done: true, identityKey: 'identity' } + }) + expect(f.commit).toHaveBeenCalledTimes(2) +}) + +test.each([ + 'before-start', + 'queued-start', + 'after-start', + 'reading', + 'after-read', + 'preparing', + 'after-prepare', + 'committing', + 'committed' +])('cancellation at %s reports the durable outcome and stops the next write', async boundary => { + const f = session() + const controller = new AbortController() + const states: string[] = [] + if (boundary === 'before-start') controller.abort() + if (boundary === 'queued-start') + f.commit.mockImplementationOnce(async work => { + controller.abort() + return await work() + }) + if (boundary === 'after-start') + (f.destination.begin as jest.Mock).mockImplementationOnce(async () => { + controller.abort() + return f.checkpoint + }) + if (boundary === 'after-read') + (f.view.readPage as jest.Mock).mockImplementationOnce(async () => { + controller.abort() + return { rows: [], payloadBytes: 0, done: true } + }) + if (boundary === 'after-prepare') + (f.destination.prepare as jest.Mock).mockImplementationOnce(async () => { + controller.abort() + return f.apply + }) + const result = await runSnapshotSyncSession(f.input, { + signal: controller.signal, + onProgress: progress => { + states.push(progress.state) + if (progress.state === boundary) controller.abort() + } + }) + const committed = boundary === 'committed' + expect(result.status).toBe('cancelled') + expect(result.pages).toBe(Number(committed)) + expect(f.apply).toHaveBeenCalledTimes(Number(committed)) + expect(states.slice(-2)).toEqual(['cancelling', 'cancelled']) + expect(states).not.toContain('completed') + expect(f.destination.begin).toHaveBeenCalledTimes(Number(!['before-start', 'queued-start'].includes(boundary))) + expect(f.view.readPage).toHaveBeenCalledTimes( + Number(['after-read', 'preparing', 'after-prepare', 'committing', 'committed'].includes(boundary)) + ) + expect(f.destination.prepare).toHaveBeenCalledTimes( + Number(['after-prepare', 'committing', 'committed'].includes(boundary)) + ) + const stages = ['reading', 'preparing', 'committing', 'committed'] + const stageCount = { + 'before-start': 0, + 'queued-start': 0, + 'after-start': 0, + reading: 1, + 'after-read': 1, + preparing: 2, + 'after-prepare': 2, + committing: 3, + committed: 4 + }[boundary]! + expect(states).toEqual([...stages.slice(0, stageCount), 'cancelling', 'cancelled']) +}) + +test.each([ + ['version', 2], + ['snapshotId', 'another-view'], + ['table', 'txLabels'], + ['after', [9]] +])('a changed acknowledged cursor %s cannot become the next read position', async (field, value) => { + const f = session() + const cursor = { version: 1 as const, snapshotId: 'b'.repeat(64), table: 'provenTxReqs' as const, after: [8] } + ;(f.view.readPage as jest.Mock).mockResolvedValue({ + rows: [{ provenTxReqId: 8 }], + payloadBytes: 128, + done: false, + cursor + }) + f.apply.mockResolvedValue({ + inserts: 1, + updates: 0, + checkpoint: { ...f.checkpoint, sequence: 12, cursor: { ...cursor, [field as string]: value } } + }) + await expect(runSnapshotSyncSession(f.input, {})).rejects.toThrow('acknowledgement does not match') + expect(f.view.readPage).toHaveBeenCalledTimes(1) +}) + +test('a nonterminal acknowledgement advances a detached cursor before finishing its table', async () => { + const f = session() + const cursor = { version: 1 as const, snapshotId: 'b'.repeat(64), table: 'provenTxReqs' as const, after: [8] } + ;(f.view.readPage as jest.Mock).mockResolvedValueOnce({ + rows: [{ provenTxReqId: 8 }], + payloadBytes: 128, + done: false, + cursor + }) + const acknowledged: SnapshotSyncCheckpoint = { ...f.checkpoint, sequence: 12, cursor: { ...cursor, after: [8] } } + f.apply.mockResolvedValueOnce({ inserts: 1, updates: 0, checkpoint: acknowledged }) + f.apply.mockResolvedValueOnce({ + inserts: 0, + updates: 0, + checkpoint: { ...f.checkpoint, sequence: 13, tableIndex: 12, done: true, cursor: undefined } + }) + const result = await runSnapshotSyncSession(f.input, { + onProgress: progress => { + if (progress.state === 'committed' && progress.pages === 1) { + acknowledged.cursor!.after[0] = 999 + acknowledged.sequence = 999 + } + } + }) + expect((f.view.readPage as jest.Mock).mock.calls[1][1]).toEqual(cursor) + expect(result).toMatchObject({ status: 'completed', pages: 2, inserts: 1, updates: 0 }) +}) + +test.each(['closed', 'expired', 'cleanup-failed'] as const)( + 'source %s while queued prevents a destination write', + async outcome => { + const f = session() + const cleanup = new Error('physical cleanup failed') + const options: SyncSessionOptions = { + onProgress: progress => { + if (progress.state !== 'committing') return + Object.defineProperty(f.view, 'isOpen', { value: false }) + if (outcome === 'expired') Object.defineProperty(f.view, 'expiresAt', { value: 0 }) + if (outcome === 'cleanup-failed') { + const rejected = Promise.reject(cleanup) + void rejected.catch(() => undefined) + Object.defineProperty(f.view, 'closed', { value: rejected }) + } + } + } + const running = runSnapshotSyncSession(f.input, options) + if (outcome === 'expired') await expect(running).rejects.toBeInstanceOf(SnapshotResourceLimitError) + else if (outcome === 'cleanup-failed') await expect(running).rejects.toBe(cleanup) + else await expect(running).rejects.toThrow('source closed') + expect(f.apply).not.toHaveBeenCalled() + } +) + +test('an already completed destination session does not read or prepare another page', async () => { + const f = session() + f.checkpoint.tableIndex = 12 + f.checkpoint.done = true + const result = await runSnapshotSyncSession(f.input, {}) + expect(result).toMatchObject({ status: 'completed', pages: 0, snapshotCheckpoint: { done: true } }) + expect(f.view.readPage).not.toHaveBeenCalled() + expect(f.destination.prepare).not.toHaveBeenCalled() +}) + +test.each([ + ['version', 2], + ['sessionId', 'changed'], + ['identityKey', 'another-profile'], + ['sourceStorageIdentityKey', 'another-source'], + ['destinationStorageIdentityKey', 'another-destination'], + ['snapshotId', 'another-view'], + ['sequence', 11], + ['tableIndex', 11], + ['done', false], + ['cursor', { version: 1, snapshotId: 'b'.repeat(64), table: 'provenTxReqs', after: [7] }] +])('a mismatched acknowledged %s rejects before counting or reading another page', async (field, value) => { + const f = session() + const commit = await f.apply() + f.apply.mockClear() + f.apply.mockResolvedValue({ ...commit, checkpoint: { ...commit.checkpoint, [field as string]: value } }) + const states: string[] = [] + await expect( + runSnapshotSyncSession(f.input, { onProgress: progress => states.push(progress.state) }) + ).rejects.toThrow('acknowledgement does not match') + expect(f.apply).toHaveBeenCalledTimes(1) + expect(f.view.readPage).toHaveBeenCalledTimes(1) + expect(states).not.toContain('committed') + expect(states).not.toContain('completed') +}) + +test('progress timings separate read, preparation, queue and commit costs', async () => { + const f = session() + let now = 1000 + const clock = jest.spyOn(Date, 'now').mockImplementation(() => now) + const budget = jest.spyOn(SyncPageBudget.prototype, 'committed') + const progress: SyncSessionProgress[] = [] + ;(f.view.readPage as jest.Mock).mockImplementation(async () => { + now += 11 + return { rows: [], payloadBytes: 0, done: true } + }) + ;(f.destination.prepare as jest.Mock).mockImplementation(async () => { + now += 13 + return f.apply + }) + const apply = f.apply.getMockImplementation()! + f.apply.mockImplementation(async () => { + now += 19 + return await apply() + }) + let commits = 0 + f.commit.mockImplementation(async work => { + if (++commits === 2) now += 17 + return await work() + }) + try { + await runSnapshotSyncSession(f.input, { onProgress: event => progress.push(event) }) + expect(progress.find(event => event.state === 'preparing')).toMatchObject({ readMs: 11 }) + expect(progress.find(event => event.state === 'committing')).toMatchObject({ readMs: 11, prepareMs: 13 }) + expect(progress.find(event => event.state === 'committed')).toMatchObject({ + readMs: 11, + prepareMs: 13, + queueMs: 17, + commitMs: 19 + }) + expect(budget).toHaveBeenCalledWith(expect.any(Object), 43, 24) + } finally { + budget.mockRestore() + clock.mockRestore() + } +}) + +test.each([1, 10000000])('the exact byte ceiling %s remains accepted', async maxRoughSize => { + const f = session() + expect(await runSnapshotSyncSession(f.input, { maxItems: 1, maxRoughSize })).toMatchObject({ + status: 'completed', + pages: 1 + }) + expect(f.view.readPage).toHaveBeenCalledWith('provenTxReqs', f.checkpoint.cursor, { + maxRows: 1, + maxBytes: maxRoughSize + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts index f5d59380b..984bd108e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts @@ -169,6 +169,7 @@ test('provider destruction closes its idle retained view before destroying the p test('IndexedDB retains its scoped snapshot support but explicitly refuses an idle retained transaction', async () => { const source = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) expect(source.supportsReadSnapshot()).toBe(true) + expect(source.getSnapshotSync()).toBeUndefined() expect(source.supportsRetainedReadSnapshot()).toBe(false) await expect(source.openReadSnapshot()).rejects.toThrow('Retained read snapshots are not supported') }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts new file mode 100644 index 000000000..63625f934 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts @@ -0,0 +1,189 @@ +import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' +import type { RequestSyncChunkArgs, SyncChunk } from '../../sdk/WalletStorage.interfaces' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' +import { SyncPageBudget } from '../sync/SyncPageBudget' +import type { SyncSessionOptions, SyncSessionProgress, SyncSessionResult } from '../sync/syncSession' +import { snapshotSyncTables, type SnapshotSyncCheckpoint, type SnapshotSyncStorage } from './SnapshotSync' +import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' + +interface SnapshotSession { + view: WalletReadSnapshot + destination: SnapshotSyncStorage + activeStorage: string + /** Check primary generation and cancellation inside background write admission. */ + commit: (operation: () => Promise) => Promise +} + +function copyCheckpoint(value: SnapshotSyncCheckpoint): SnapshotSyncCheckpoint +function copyCheckpoint(value: SnapshotSyncCheckpoint | undefined): SnapshotSyncCheckpoint | undefined +function copyCheckpoint(value: SnapshotSyncCheckpoint | undefined): SnapshotSyncCheckpoint | undefined { + return value === undefined + ? undefined + : { ...value, cursor: value.cursor === undefined ? undefined : { ...value.cursor, after: [...value.cursor.after] } } +} + +type NotifyProgress = (state: SyncSessionProgress['state'], timing?: Partial) => void + +function validateAcknowledgement( + previous: SnapshotSyncCheckpoint, + page: { done: boolean; cursor?: WalletSnapshotCursor }, + next: SnapshotSyncCheckpoint +): void { + const tableIndex = previous.tableIndex + Number(page.done) + const expected = { + ...previous, + sequence: previous.sequence + 1, + tableIndex, + done: tableIndex === snapshotSyncTables.length + } + const bindings = [ + 'version', + 'sessionId', + 'identityKey', + 'sourceStorageIdentityKey', + 'destinationStorageIdentityKey', + 'snapshotId', + 'sequence', + 'tableIndex', + 'done' + ] as const + const cursor = page.done ? undefined : page.cursor + if ( + bindings.some(key => next[key] !== expected[key]) || + next.cursor?.version !== cursor?.version || + next.cursor?.snapshotId !== cursor?.snapshotId || + next.cursor?.table !== cursor?.table || + JSON.stringify(next.cursor?.after) !== JSON.stringify(cursor?.after) + ) { + throw new WERR_INVALID_OPERATION( + 'Snapshot destination acknowledgement does not match the committed page; reload its durable checkpoint' + ) + } +} + +async function preparePage( + session: SnapshotSession, + checkpoint: SnapshotSyncCheckpoint, + limits: { maxRows: number; maxBytes: number }, + notify: NotifyProgress, + cancelled: () => boolean +) { + notify('reading') + if (cancelled()) return undefined + const readAt = Date.now() + const page = await session.view.readPage(snapshotSyncTables[checkpoint.tableIndex], checkpoint.cursor, limits) + const readMs = Date.now() - readAt + if (cancelled()) return undefined + notify('preparing', { readMs }) + if (cancelled()) return undefined + const prepareAt = Date.now() + const apply = await session.destination.prepare(checkpoint, page) + const prepareMs = Date.now() - prepareAt + if (cancelled()) return undefined + return { page, apply, readMs, prepareMs } +} + +async function rejectClosedSnapshot(view: WalletReadSnapshot): Promise { + // Physical cleanup may need database I/O; await it after queue ownership + // was released. Its actual expiry/cancellation/read error stays authoritative. + await view.closed + if (Date.now() >= view.expiresAt) + throw new SnapshotResourceLimitError('Snapshot source expired before destination commit') + throw new WERR_INVALID_OPERATION('Snapshot source closed before destination commit') +} + +/** One packed page in flight. The caller owns source cleanup, including error/cancellation. */ +export async function runSnapshotSyncSession( + session: SnapshotSession, + options: SyncSessionOptions +): Promise { + const maxItems = options.maxItems ?? 1000 + const maxBytes = options.maxRoughSize ?? 262144 + if ( + !Number.isSafeInteger(maxItems) || + maxItems < 1 || + maxItems > 1000 || + !Number.isSafeInteger(maxBytes) || + maxBytes < 1 || + maxBytes > 10000000 + ) { + throw new WERR_INVALID_PARAMETER('limits', '1–1000 rows and 1–10000000 payload bytes') + } + const result: SyncSessionResult = { status: 'completed', mode: 'paged', pages: 0, inserts: 0, updates: 0 } + const notify = (state: SyncSessionProgress['state'], timing: Partial = {}): void => { + options.onProgress?.({ ...result, ...timing, state, snapshotCheckpoint: copyCheckpoint(result.snapshotCheckpoint) }) + } + const cancelled = (): boolean => { + if (options.signal?.aborted !== true) return false + result.status = 'cancelled' + notify('cancelling') + notify('cancelled') + return true + } + if (cancelled()) return result + const start = await session.commit(async () => + options.signal?.aborted === true ? undefined : await session.destination.begin(session.view, session.activeStorage) + ) + if (start === undefined) { + cancelled() + return result + } + result.snapshotCheckpoint = start + const budget = new SyncPageBudget() + const args: RequestSyncChunkArgs = { + identityKey: start.identityKey, + fromStorageIdentityKey: start.sourceStorageIdentityKey, + toStorageIdentityKey: start.destinationStorageIdentityKey, + maxItems, + maxRoughSize: maxBytes, + offsets: [] + } + while (!result.snapshotCheckpoint.done) { + if (cancelled()) return result + const checkpoint = result.snapshotCheckpoint + const table = snapshotSyncTables[checkpoint.tableIndex] + const prepared = await preparePage( + session, + checkpoint, + { maxRows: budget.apply(args).maxItems, maxBytes }, + notify, + cancelled + ) + if (prepared === undefined) return result + const { page, apply, readMs, prepareMs } = prepared + notify('committing', { readMs, prepareMs }) + const queuedAt = Date.now() + let commitAt = queuedAt + const committed = await session.commit(async () => { + if (options.signal?.aborted === true) return undefined + if (!session.view.isOpen) return 'source-closed' as const + commitAt = Date.now() + return await apply() + }) + if (committed === 'source-closed') return await rejectClosedSnapshot(session.view) + if (committed === undefined) { + cancelled() + return result + } + validateAcknowledgement(checkpoint, page, committed.checkpoint) + const commitMs = Date.now() - commitAt + result.pages++ + result.inserts += committed.inserts + result.updates += committed.updates + result.snapshotCheckpoint = copyCheckpoint(committed.checkpoint) + budget.committed( + { + userIdentityKey: start.identityKey, + fromStorageIdentityKey: start.sourceStorageIdentityKey, + toStorageIdentityKey: start.destinationStorageIdentityKey, + [table]: page.rows + } as SyncChunk, + readMs + prepareMs + commitMs, + readMs + prepareMs + ) + notify('committed', { readMs, prepareMs, queueMs: commitAt - queuedAt, commitMs }) + if (cancelled()) return result + } + notify('completed') + return result +} diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts index e22614bae..d31f3c560 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts @@ -1,3 +1,4 @@ +import type { SnapshotSyncCheckpoint } from '../snapshot/SnapshotSync' import type { ProcessSyncChunkResult, RequestSyncChunkArgs, @@ -14,6 +15,8 @@ import { assertSyncProgress, throwSyncResultError } from './syncFailure' export interface SyncSessionOptions { /** Cancellation waits for an in-flight commit acknowledgement; it never rolls back an acknowledged page. */ signal?: AbortSignal + /** Local snapshot retention, default five minutes and at most one hour. Expiry requires a new view. */ + snapshotLifetimeMs?: number /** Additional page ceilings; defaults are 1,000 rows and 262,144 rough encoded bytes. */ maxItems?: number maxRoughSize?: number @@ -27,6 +30,8 @@ export interface SyncSessionProgress { pages: number inserts: number updates: number + /** Present for negotiated local snapshot sync; legacy offsets remain separate. */ + snapshotCheckpoint?: SnapshotSyncCheckpoint checkpoint?: SyncCheckpoint readMs?: number prepareMs?: number @@ -40,6 +45,8 @@ export interface SyncSessionResult { pages: number inserts: number updates: number + /** Present for negotiated local snapshot sync; legacy offsets remain separate. */ + snapshotCheckpoint?: SnapshotSyncCheckpoint checkpoint?: SyncCheckpoint } diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 7206a669d..298106fbe 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 34) + assert.equal(result.summary.propertySuites, 35) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 37) - assert.equal(result.summary.mutationTargets, 34) + assert.equal(result.summary.mutationTargets, 35) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index d29343c0e..3f1ee939f 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -92,14 +92,24 @@ Existing unique keys avoid changing legacy OFFSET traversal. Composite storage order is explicit and differs from canonical archive order. This provides bounded local pages, with explicit oversized-row refusal pending streaming. -These checkpoints are only part of S2/P1/S4. They do not yet implement indexed -identity/update predicates and commit-order high-water positions, authenticated -remote views, durable source-view checkpoints, -streaming, bounded push/backup work or staged restore. -IndexedDB writers wait during capture and the legacy helpers still materialize -the document. A local MySQL 8.4.11 fixture confirms repeatable-read capture under -an independent writer, read-only enforcement, unchanged session defaults and -failure cleanup. Deployed MySQL/PXC behavior is not qualified by that fixture. +The durable local integration checkpoint connects supported ordinary push, pull +and backup calls to those pages. A dedicated SQLite-WAL/static-MySQL reader leaves +foreground pool capacity available. The destination commits entity rows, +normalized bounded ID mappings and its cursor atomically. An additive auxiliary +migration and database-owned primary epoch fence stale pages, including independent +away-and-back changes. Same-view lost acknowledgements resume the destination +checkpoint; replacement source views restart traversal with retained mappings. +Tests terminate the destination process before a row, before checkpoint update, +before transaction commit and after commit/before acknowledgement, then recover +and finish without duplicate rows or partial checkpoints. + +These checkpoints advance parts of S1/S2/P1/S4. They do not complete primary +reconciliation, indexed identity/update predicates and commit-order high-water +positions, authenticated remote views, durable source views, streaming or staged +restore. Large-row/reference/retention limits still use serialized fallback. +IndexedDB writers wait during capture and legacy archive helpers still materialize +the document. Local MySQL fixtures qualify their isolated version/configuration; +they do not qualify deployed MySQL/PXC behavior or physical mobile execution. For every checkpoint record the exact source revision, commands, fixture and platform, measured result, compatibility result and remaining limitation. Link From 9af6fb6da2981cb2286408214d5b66e10869baf7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 02:56:47 -0700 Subject: [PATCH 042/127] fix(wallet-toolbox): preserve sync primary metadata by direction --- docs/guides/wallet-sync-reliability.md | 5 ++ docs/reference/package-api-migrations.md | 6 +- governance/mutation-testing/targets.mjs | 7 +++ governance/package-release-notes.json | 6 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 2 + packages/wallet/wallet-toolbox/README.md | 3 + .../src/storage/WalletStorageManager.ts | 22 ++++--- .../snapshot/SnapshotSync.integration.test.ts | 58 +++++++++++++++++++ .../src/storage/sync/syncSession.ts | 5 +- 9 files changed, 97 insertions(+), 17 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index bdfb25e5a..24a003492 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -259,6 +259,11 @@ A session binds the wallet identity, source/destination storage identities, network, source view, selected primary and its epoch. Rows, normalized ID mappings and the destination cursor commit together. Source user metadata joins the first page commit; cancellation before that commit leaves the selected primary intact. +Push and backup merge the source view's stored primary metadata using the existing +timestamp rule; they do not replace it with a manager's older cached selection. +Pull retains the destination manager's selection. Serialized fallback preserves +the same directional rules. This compatibility behavior does not refresh the +manager cache or implement primary reconciliation. A prepared page is single-use and stale checkpoints reject. If an acknowledgement is lost while the same source view remains alive, read the destination checkpoint and resume it. When the source view is lost, open a new view and restart traversal diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 4b449ab33..ae62d2ab2 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -523,7 +523,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. - Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -537,7 +537,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. - Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -549,7 +549,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. - Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 3cb8b0f05..4d228b0b2 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -274,6 +274,13 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/runSnapshotSyncSession.ts', 'src/storage/schema/snapshotSyncMigration.ts', 'src/storage/schema/entities/mergeSyncChunkEntities.ts', + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/sync/syncSession.ts', + 'if (chunk.user != null && session.activeStorage', + "notify('preparing', { readMs })" + ), sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 1e6fb63ae..736f73d33 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,21 +217,21 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters.", + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache.", "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters.", + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache.", "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters.", + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache.", "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index df055c060..99e168eb4 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -10,6 +10,8 @@ attention to changes that materially alter behavior or extend functionality. a dedicated source reader and short, fair destination commits. Add resumable push progress/cancellation. Commit rows, normalized ID maps and durable cursors together; reject stale sessions and independent primary ABA transitions. + Preserve source primary metadata for push/backup and destination selection + for pull, including serialized fallback when the manager's cache is older. Add the version-one auxiliary schema migration and primary-epoch trigger. Existing legacy checkpoint JSON is preserved. Unsupported configurations and explicit row/reference/retention limits retain serialized fallback; genuine diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 21811c7d6..b4c721729 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -1244,6 +1244,9 @@ atomically. `syncToWriterResumable` joins `syncFromReaderResumable` for progress cancellation. A dedicated reader preserves foreground pool capacity. SQLite requires file-backed WAL; MySQL requires a static database connection. Unsupported providers, oversized rows and retention limits use the serialized fallback. +Push and backup propagate the source view's stored primary selection, even when +the manager's cached selection is older. Pull keeps its destination selection. +The same direction-specific behavior applies during serialized fallback. Apply migration `2026-09-30-001 add durable snapshot sync` through `migrate()`. It adds auxiliary session/mapping/primary-epoch tables and a primary-change trigger; diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index d1ba86d8b..1254babf4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -783,6 +783,7 @@ export class WalletStorageManager implements sdk.WalletStorage { private async runSnapshotCopy( view: WalletReadSnapshot, destination: SnapshotSyncStorage, + direction: 'push' | 'pull', options: SyncSessionOptions, generation: number, active: sdk.WalletStorageProvider @@ -792,7 +793,7 @@ export class WalletStorageManager implements sdk.WalletStorage { { view, destination, - activeStorage: this.getActiveUser().activeStorage, + activeStorage: direction === 'push' ? view.user.activeStorage : this.getActiveUser().activeStorage, commit: operation => this.withAccess( () => { @@ -821,6 +822,7 @@ export class WalletStorageManager implements sdk.WalletStorage { private async runLegacySnapshotFallback( reader: sdk.WalletStorageSyncReader, writer: sdk.WalletStorageProvider, + direction: 'push' | 'pull', options: SyncSessionOptions, selected: { generation: number; active: sdk.WalletStorageProvider } ): Promise { @@ -837,7 +839,7 @@ export class WalletStorageManager implements sdk.WalletStorage { writer, mode: 'exclusive', atomicCheckpoint: false, - activeStorage: this.getActiveUser().activeStorage, + activeStorage: direction === 'pull' ? this.getActiveUser().activeStorage : undefined, loadRequest: () => this.loadSyncRequest(this._authId, writer, readerSettings, writerSettings.storageIdentityKey), commit: operation => operation() @@ -850,6 +852,7 @@ export class WalletStorageManager implements sdk.WalletStorage { private async trySnapshotCopy( reader: sdk.WalletStorageSyncReader, writer: sdk.WalletStorageProvider, + direction: 'push' | 'pull', options: SyncSessionOptions = {}, selected = { generation: this.generation, active: this.getActive() } ): Promise { @@ -869,6 +872,7 @@ export class WalletStorageManager implements sdk.WalletStorage { return await this.runSnapshotCopy( view, destination, + direction, { ...options, onProgress: progress => { @@ -898,6 +902,7 @@ export class WalletStorageManager implements sdk.WalletStorage { const result = await this.runLegacySnapshotFallback( reader, writer, + direction, { ...options, onProgress: progress => { @@ -932,7 +937,7 @@ export class WalletStorageManager implements sdk.WalletStorage { const auth = await this.getAuth() if (activeSync == null) { - const snapshot = await this.trySnapshotCopy(reader, this.getActive(), { maxRoughSize: 10000000 }) + const snapshot = await this.trySnapshotCopy(reader, this.getActive(), 'pull', { maxRoughSize: 10000000 }) if (snapshot !== undefined) return { inserts: snapshot.inserts, @@ -1010,7 +1015,7 @@ export class WalletStorageManager implements sdk.WalletStorage { const writer = this.getActive() const writerSettings = writer.getSettings() assertSyncNetwork(readerSettings, writerSettings) - const snapshot = await this.trySnapshotCopy(reader, writer, options) + const snapshot = await this.trySnapshotCopy(reader, writer, 'pull', options) if (snapshot !== undefined) return snapshot const generation = this.generation const activeStorage = this.getActiveUser().activeStorage @@ -1072,7 +1077,7 @@ export class WalletStorageManager implements sdk.WalletStorage { const writerSettings = await writer.makeAvailable() await this.preflightManagedNetworks(writerSettings) await this.getAuth() - const snapshot = await this.trySnapshotCopy(this.getActive(), writer, options) + const snapshot = await this.trySnapshotCopy(this.getActive(), writer, 'push', options) if (snapshot !== undefined) return snapshot return await this.runAsSync(async reader => { const settings = reader.getSettings() @@ -1081,7 +1086,6 @@ export class WalletStorageManager implements sdk.WalletStorage { { reader, writer, - activeStorage: this.getActiveUser().activeStorage, atomicCheckpoint: false, mode: 'exclusive', loadRequest: () => this.loadSyncRequest(auth, writer, settings, writerSettings.storageIdentityKey), @@ -1108,7 +1112,7 @@ export class WalletStorageManager implements sdk.WalletStorage { if (activeSync == null) { await this.getAuth() - const snapshot = await this.trySnapshotCopy(this.getActive(), writer, { + const snapshot = await this.trySnapshotCopy(this.getActive(), writer, 'push', { maxRoughSize: 10000000, onProgress: progress => { log += this.committedPageLog(progress, progLog) @@ -1187,8 +1191,8 @@ export class WalletStorageManager implements sdk.WalletStorage { } } const result = - (await this.trySnapshotCopy(selected.active, backup.storage, options, selected)) ?? - (await this.runLegacySnapshotFallback(selected.active, backup.storage, options, selected)) + (await this.trySnapshotCopy(selected.active, backup.storage, 'push', options, selected)) ?? + (await this.runLegacySnapshotFallback(selected.active, backup.storage, 'push', options, selected)) log += progLog( `${result.mode === 'paged' ? 'snapshot' : 'serialized'} complete: ${result.inserts} inserts, ${result.updates} updates\n` ) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts index a44df4ce4..3bba7bfec 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts @@ -11,6 +11,7 @@ import { snapshotSyncTables, type SnapshotSyncCheckpoint, type SnapshotSyncStora import type { WalletReadSnapshot } from './WalletReadSnapshot' import { runSnapshotSyncSession } from './runSnapshotSyncSession' import { KnexSnapshotSyncDestination } from './KnexSnapshotSyncDestination' +import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' import { runInSeries } from '../../utility/runInSeries' import { WERR_UNAUTHORIZED } from '../../sdk/WERR_errors' @@ -484,6 +485,63 @@ test('destroy during the SQLite WAL probe rejects opening before creating a priv expect(privateReader).not.toHaveBeenCalled() }) +const primaryCopyModes = ['snapshot', 'non-WAL', 'disabled-source', 'disabled-destination', 'resource-limit'] as const +async function primaryCopyFixture(mode: (typeof primaryCopyModes)[number]) { + const result = await fixture( + 1, + mode !== 'non-WAL', + mode === 'disabled-source' ? 'source' : mode === 'disabled-destination' ? 'destination' : undefined + ) + if (mode === 'resource-limit') { + const capability = result.source.getSnapshotSync()! + jest.spyOn(result.source, 'getSnapshotSync').mockReturnValue({ + ...capability, + openSource: async () => { + throw new SnapshotResourceLimitError('Synthetic retention admission limit') + } + }) + } + // The manager deliberately retains its earlier primary while an independent + // writer records a newer source selection. Push must forward the stored row. + await result.source.updateUser(result.user.userId, { + activeStorage: 'new-primary', + updated_at: new Date('2030-01-01T00:00:00.000Z') + }) + expect(result.manager.getActiveUser().activeStorage).toBe('source') + return result +} + +describe.each(primaryCopyModes)('primary metadata through %s', mode => { + test.each(['push', 'resumable-push', 'backup', 'borrowed-push'] as const)( + '%s preserves the newer source selection', + async operation => { + const { source, destination, manager, user } = await primaryCopyFixture(mode) + const auth = await manager.getAuth() + if (operation === 'push') await manager.syncToWriter(auth, destination) + else if (operation === 'resumable-push') await manager.syncToWriterResumable(auth, destination) + else if (operation === 'backup') await manager.updateBackups() + else await manager.runAsSync(active => manager.syncToWriter(auth, destination, active)) + expect((await destination.findUserByIdentityKey(identity))!.activeStorage).toBe('new-primary') + expect((await source.findUserByIdentityKey(identity))!.activeStorage).toBe('new-primary') + expect(await source.findTxLabels({ partial: { userId: user.userId } })).toHaveLength(1) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(1) + } + ) + + test.each(['pull', 'resumable-pull'] as const)('%s retains the destination selection', async operation => { + const { source, destination } = await primaryCopyFixture(mode) + const user = (await destination.findUserByIdentityKey(identity))! + await destination.updateUser(user.userId, { activeStorage: 'destination' }) + const manager = new WalletStorageManager(identity, destination) + await manager.makeAvailable() + if (operation === 'pull') await manager.syncFromReader(identity, source) + else await manager.syncFromReaderResumable(identity, source) + expect((await destination.findUserByIdentityKey(identity))!.activeStorage).toBe('destination') + expect((await source.findUserByIdentityKey(identity))!.activeStorage).toBe('new-primary') + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(1) + }) +}) + test('snapshot pull preserves the active destination and remaps into an occupied profile', async () => { const { source, destination } = await fixture(4) const { user: foreign } = await destination.findOrInsertUser(foreignIdentity) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts index d31f3c560..40f4cb862 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts @@ -54,7 +54,8 @@ interface PullSession { reader: WalletStorageSyncReader writer: WalletStorageSync mode: 'paged' | 'exclusive' - activeStorage: string + /** Pull preserves its destination selection; push forwards the source row unchanged. */ + activeStorage?: string atomicCheckpoint: boolean loadRequest: () => Promise prepare?: (args: RequestSyncChunkArgs, chunk: SyncChunk) => Promise<() => Promise> @@ -97,7 +98,7 @@ async function readAndPreparePage( ) { throw new WERR_INVALID_PARAMETER('chunk', 'bound to this sync source, destination and wallet identity') } - if (chunk.user != null) chunk.user.activeStorage = session.activeStorage + if (chunk.user != null && session.activeStorage !== undefined) chunk.user.activeStorage = session.activeStorage notify('preparing', { readMs }) if (cancelled()) return undefined const prepareAt = Date.now() From 3129626208a46fd3c9362355bf07304ac078f61d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 03:14:38 -0700 Subject: [PATCH 043/127] docs(wallet-toolbox): distinguish retained primitive from backup integration --- packages/wallet/wallet-toolbox/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index b4c721729..45a6707a4 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -42,10 +42,11 @@ materialize the full document/file, and IndexedDB writers wait during capture. SQL providers also expose `supportsRetainedReadSnapshot` / `openReadSnapshot` for a local view held across idle reads, with one view per provider, one read at a time, and bounded lifetime/cancellation. Await `closed`/`close()` for physical -cleanup. The view occupies a connection; driver deadlines remain separate and a -single-connection pool cannot serve other work until release. IndexedDB and -remote RPC do not expose retained views. This does not yet yield ordinary backup -work or add a bounded paging API. See the +cleanup. This low-level view occupies a caller-pool connection; driver deadlines +remain separate and a single-connection pool cannot serve other work until +release. The [durable local SQL backup integration](#durable-local-sql-backup-integration-215-candidate) +below adds bounded pages and uses a dedicated reader to preserve foreground +capacity. IndexedDB and remote RPC do not expose retained views. See the [retained view contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#retained-local-sql-read-views-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. From 6368cd30de1d250eb3bd1184bff46b67b3321dbd Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 03:20:20 -0700 Subject: [PATCH 044/127] test(wallet-toolbox): cover malformed packed proof admission --- .../storage/methods/validateSyncProof.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.test.ts b/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.test.ts index 63e92d176..5532f4edf 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/validateSyncProof.test.ts @@ -180,3 +180,28 @@ test('validates packed proof bytes without expanding or changing them', async () expect(candidate.merklePath).toBe(merklePath) expect(() => assertSyncProofReplacementAuthorized(candidate)).not.toThrow() }) + +describe.each([ + ['rawTx', 'raw transaction is required'], + ['merklePath', 'Merkle path is required'] +] as const)('%s input admission', (field, reason) => { + test.each([ + ['empty number array', []], + ['empty packed array', new Uint8Array()], + ['string', '01'], + ['array-like object', { 0: 1, length: 1 }], + ['missing value', undefined], + ['null', null] + ])('rejects %s before service access or proof authorization', async (_description, bytes) => { + const f = fixture() + const services = jest.spyOn(f.storage, 'getServices') + const candidate = { ...f.candidate, [field]: bytes as unknown as number[] } + const original = structuredClone(candidate) + await expect(validateSyncProof(f.storage, candidate)).rejects.toThrow(reason) + expect(services).not.toHaveBeenCalled() + expect(candidate).toEqual(original) + expect(() => assertSyncProofReplacementAuthorized(candidate)).toThrow( + 'replacement requires active-chain validation' + ) + }) +}) From 3446878ed56cc7ea2cf76e3e72608fe793bb531f Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 04:16:39 -0700 Subject: [PATCH 045/127] refactor(wallet-toolbox): make snapshot sequencing explicit --- docs/guides/wallet-sync-reliability.md | 6 ++++- governance/mutation-testing/targets.mjs | 4 +++- .../src/storage/WalletStorageManager.ts | 8 ++++--- .../schema/entities/mergeSyncChunkEntities.ts | 7 +++--- .../storage/schema/snapshotSyncMigration.ts | 5 +++-- .../snapshot/KnexSnapshotSyncDestination.ts | 6 +++-- .../src/storage/snapshot/SnapshotSyncRows.ts | 22 ++++++++++++------- 7 files changed, 38 insertions(+), 20 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 24a003492..501520cbd 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -231,7 +231,11 @@ preparation run outside manager ownership; only destination admission and each atomic page commit enter the fair background queue. Foreground reads and writes can proceed between those commits. Existing `progLog` callbacks receive each committed page and the completion summary, including during serialized fallback; -their returned text remains part of the ordinary result log. Primary reconciliation still uses its existing +their returned text remains part of the ordinary result log. Backup destinations, +entity dependencies, proof checks and 128-ID SQL batches run sequentially through +the lazy series coordinator. A failure stops before the next operation starts; +neither parallel database work nor an eager promise queue is introduced. +Primary reconciliation still uses its existing exclusive path and remains required work in the full program. The source gets a dedicated one-connection pool, preserving the original pool's diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 4d228b0b2..1369df78a 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -268,6 +268,7 @@ export function buildMutationTargets(repositoryRoot) { propertyTest: 'packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts', mutate: [ + 'src/utility/runInSeries.ts', 'src/storage/snapshot/SnapshotSync.ts', 'src/storage/snapshot/SnapshotSyncRows.ts', 'src/storage/snapshot/KnexSnapshotSyncDestination.ts', @@ -360,7 +361,8 @@ export function buildMutationTargets(repositoryRoot) { '/src/storage/schema/snapshotSyncMigration.test.ts', '/src/storage/methods/validateSyncProof.test.ts', '/src/storage/sync/syncFailure.test.ts', - '/src/storage/sync/syncSession.test.ts' + '/src/storage/sync/syncSession.test.ts', + '/src/utility/__tests__/runInSeries.test.ts' ], { config: { diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index 1254babf4..970b05687 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -854,8 +854,9 @@ export class WalletStorageManager implements sdk.WalletStorage { writer: sdk.WalletStorageProvider, direction: 'push' | 'pull', options: SyncSessionOptions = {}, - selected = { generation: this.generation, active: this.getActive() } + selection?: { generation: number; active: sdk.WalletStorageProvider } ): Promise { + const selected = selection ?? { generation: this.generation, active: this.getActive() } const source = reader instanceof StorageProvider ? reader.getSnapshotSync() : undefined const destination = writer instanceof StorageProvider ? writer.getSnapshotSync() : undefined if (source === undefined || destination === undefined || reader === writer) return undefined @@ -1183,7 +1184,8 @@ export class WalletStorageManager implements sdk.WalletStorage { const selected = { generation: this.generation, active: this.getActive() } const backups = [...(this._backups as ManagedStorage[])] let log = progLog(`BACKUP CURRENT ACTIVE TO ${backups.length} STORES\n`) - for (const backup of backups) { + // One source view at a time; stop before starting another backup on failure. + await runInSeries(backups, async backup => { const options: SyncSessionOptions = { maxRoughSize: 10000000, onProgress: progress => { @@ -1196,7 +1198,7 @@ export class WalletStorageManager implements sdk.WalletStorage { log += progLog( `${result.mode === 'paged' ? 'snapshot' : 'serialized'} complete: ${result.inserts} inserts, ${result.updates} updates\n` ) - } + }) return log } return await this.runAsSync(async sync => { diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/mergeSyncChunkEntities.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/mergeSyncChunkEntities.ts index 3263d5558..05261d42f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/mergeSyncChunkEntities.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/mergeSyncChunkEntities.ts @@ -1,5 +1,6 @@ import type { SyncChunk, TrxToken } from '../../../sdk/WalletStorage.interfaces' import { maxDate } from '../../../utility/utilityHelpers' +import { runInSeries } from '../../../utility/runInSeries' import type { EntityStorage, SyncMap } from './EntityBase' import { EntityCertificate } from './EntityCertificate' import { EntityCertificateField } from './EntityCertificateField' @@ -57,8 +58,8 @@ export async function mergeSyncChunkEntities( } } - // Merge everything else... - for (const me of mes) { + // Child entities depend on ID mappings committed by their predecessors. + await runInSeries(mes, async me => { const r = await me.merge(since, writer, userId, syncMap, trx) // The counts become the offsets for the next chunk. me.esm.count += me.stateArray?.length || 0 @@ -67,7 +68,7 @@ export async function mergeSyncChunkEntities( maxUpdated_at = maxDate(maxUpdated_at, me.esm.maxUpdated_at) // If any entity type either did not report results or if there were at least one, then we aren't done. if (me.stateArray === undefined || me.stateArray.length > 0) done = false - } + }) if (done) for (const me of mes) me.esm.count = 0 return { done, maxUpdated_at, updates, inserts } diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.ts index 27e6cc406..0a0ded97b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSyncMigration.ts @@ -1,4 +1,5 @@ import type { Knex } from 'knex' +import { runInSeries } from '../../utility/runInSeries' export const SNAPSHOT_SYNC_MIGRATION = '2026-09-30-001 add durable snapshot sync' @@ -54,7 +55,7 @@ export async function addSnapshotSyncTables(knex: Knex): Promise { // A primary may change away and back to the same identity. A database-owned // counter fences that ABA transition, including older/independent writers. if (mysql) { - for (const table of ['snapshot_sync_sessions', 'snapshot_sync_ids', 'snapshot_sync_primary_epochs']) { + await runInSeries(['snapshot_sync_sessions', 'snapshot_sync_ids', 'snapshot_sync_primary_epochs'], async table => { const constraint = table + '_user' const [existing]: Array> = await knex.raw( "SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ? AND CONSTRAINT_TYPE = 'FOREIGN KEY'", @@ -64,7 +65,7 @@ export async function addSnapshotSyncTables(knex: Knex): Promise { await knex.schema.table(table, definition => { definition.foreign('userId', constraint).references('userId').inTable('users') }) - } + }) const [triggers]: Array> = await knex.raw( 'SELECT TRIGGER_NAME AS name FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME = ?', ['snapshot_sync_primary_change'] diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts index 614882952..5b19e51f5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts @@ -4,6 +4,7 @@ import type { StorageKnex } from '../StorageKnex' import type { SyncChunk, TrxToken } from '../../sdk/WalletStorage.interfaces' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' import { verifyOne } from '../../utility/utilityHelpers' +import { runInSeries } from '../../utility/runInSeries' import type { TableProvenTx, TableUser } from '../schema/tables' import { mergeSyncChunkEntities } from '../schema/entities/mergeSyncChunkEntities' import { sameSyncProof } from '../methods/validateSyncProof' @@ -302,13 +303,14 @@ export class KnexSnapshotSyncDestination { } private async verifyProofs(proofs: Map, trx: TrxToken): Promise { - for (const previous of [...proofs.values()].sort((left, right) => left.txid.localeCompare(right.txid))) { + const ordered = [...proofs.values()].sort((left, right) => left.txid.localeCompare(right.txid)) + await runInSeries(ordered, async previous => { const rows = await this.storage.findProvenTxs({ partial: { txid: previous.txid }, trx }) if (rows.length !== 1 || !sameSyncProof(rows[0], previous)) { throw new WERR_INVALID_OPERATION( 'Proof changed during snapshot preparation; resume from its durable checkpoint' ) } - } + }) } } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts index eed27b31d..4d716dcaf 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts @@ -6,6 +6,7 @@ import { createSyncMap, type SyncMap } from '../schema/entities/EntityBase' import { snapshotSyncPage } from '../sync/snapshotSyncPage' import { snapshotSyncTables, type SnapshotSyncCheckpoint, type SnapshotSyncTable } from './SnapshotSync' import type { WalletSnapshotPage } from './WalletReadSnapshot' +import { runInSeries } from '../../utility/runInSeries' const entities: Record = { provenTxs: 'provenTx', @@ -198,6 +199,11 @@ function collectReferences(sourceUserId: number, table: SnapshotSyncTable, chunk return refs } +/** Produce one SQL parameter batch at a time without creating an eager query queue. */ +function* idBatches(values: T[]): Generator { + for (let offset = 0; offset < values.length; offset += 128) yield values.slice(offset, offset + 128) +} + /** Load only incoming IDs and their parent references; never parse or overwrite the legacy JSON map. */ export async function loadSnapshotIdMap( k: Knex, @@ -209,17 +215,16 @@ export async function loadSnapshotIdMap( const { wanted, required } = collectReferences(sourceUserId, table, chunk) const map = createSyncMap() const known = new Map>() - for (const [entity, values] of wanted) { - const list = [...values] - for (let offset = 0; offset < list.length; offset += 128) { + await runInSeries(wanted, async ([entity, values]) => { + await runInSeries(idBatches([...values]), async batch => { const found: IdRow[] = await k('snapshot_sync_ids') .select('incomingId', 'localId') .where({ ...scope, entity }) - .whereIn('incomingId', list.slice(offset, offset + 128)) + .whereIn('incomingId', batch) for (const row of found) map[entity].idMap[row.incomingId] = row.localId - } + }) known.set(entity, new Set(Object.keys(map[entity].idMap).map(Number))) - } + }) for (const [entity, values] of required) { for (const id of values) { if (map[entity].idMap[id] === undefined) @@ -237,8 +242,9 @@ export async function loadSnapshotIdMap( additions.push({ ...scope, entity, incomingId: id, localId }) } } - for (let offset = 0; offset < additions.length; offset += 128) - await k('snapshot_sync_ids').insert(additions.slice(offset, offset + 128)) + await runInSeries(idBatches(additions), async batch => { + await k('snapshot_sync_ids').insert(batch) + }) } } } From 3f7072ce7808420c5417fdcb80bfe5f2b8bd3257 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 06:21:15 -0700 Subject: [PATCH 046/127] ci: bound complete wallet snapshot mutation campaigns --- .github/workflows/ci.yml | 6 ++--- .github/workflows/mutation-tests.yml | 4 ++- docs/reference/test-quality-governance.md | 27 +++++++++++++------ scripts/ci-orchestration.test.mjs | 32 ++++++++++++++++++++++- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fd039ebe..d4e8a51eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,9 +460,9 @@ jobs: if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.mutation-targets != '[]' needs: prepare runs-on: ubuntu-latest - # The governed air-gap codec target currently instruments 352 mutants and - # legitimately exceeds 20 minutes on a hosted runner. - timeout-minutes: 45 + # Full wallet snapshot campaigns exceeded the hosted 45-minute deadline. + # Preserve their tests, mutants and per-test deadlines with a bounded allowance. + timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 390e8aa77..e8946af39 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -78,7 +78,9 @@ jobs: name: Mutation / ${{ matrix.target }} needs: prepare runs-on: ubuntu-latest - timeout-minutes: 20 + # Full wallet snapshot campaigns exceeded the hosted 45-minute deadline. + # Preserve their tests, mutants and per-test deadlines with a bounded allowance. + timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync"]'), matrix.target) && 90 || 20 }} permissions: contents: read strategy: diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 308ccf1ae..93280c7a4 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -147,8 +147,8 @@ when an arbitrary-input invariant can be stated. ## Mutation-validated fuzzing Property generation is only valuable when its assertions can detect a broken -invariant. Every one of the 25 registered property suites therefore has a -matching Stryker mutation target. The target mutates the implementation owned +invariant. Each registered property suite therefore has a matching Stryker mutation +target. The target mutates the implementation owned by that property boundary and runs the smallest relevant combination of property and deterministic regression tests. This catches weak round trips, uncorrelated generators, assertions that only prove “did not throw,” and rare @@ -180,7 +180,7 @@ fan out to all targets; selector, scoring, unrelated SDK, CI, or governance edits do not. Selector and score evaluation are covered by the zero-install repository contract. The independent `Mutation quality` workflow runs the full matrix every Sunday and can run one exact target manually. Targets execute -in parallel, reuse one workspace build, cancel unfinished siblings after a +in parallel, reuse one workspace build, let selected siblings finish after a failure, and preserve machine-readable reports for 30 days. Mutation runs use the policy's fixed 300-case fast-check seed by default so the dry run and every mutant see the same generated campaign. `FAST_CHECK_NUM_RUNS`, @@ -192,11 +192,22 @@ browser/mobile consumers, infrastructure, and runtime images. Empty image and infrastructure matrices do not allocate build runners. The standalone TypeScript conformance workflow runs only when its vectors, specifications, generator, or workflow change; SDK-dependent conformance behavior remains an -affected workspace regression. Cheap repository, dependency, scope, and Sonar -checks gate installation and compilation, matrix lanes cancel siblings on a -failure, and every CI job has a reviewed timeout instead of GitHub's six-hour -default. The zero-install orchestration tests enforce these resource and -fail-fast controls. +affected workspace regression. Cheap repository, dependency and scope checks gate installation and +compilation; Sonar remains required by the final merge gate. Selected matrix +lanes finish after a sibling failure, and every CI job has a reviewed timeout +instead of GitHub's six-hour default. The zero-install orchestration tests +enforce these resource and complete-campaign controls. + +The `wallet-retained-snapshot` and `wallet-snapshot-sync` mutation jobs have a +90-minute limit in PR CI and the standalone mutation workflow. Both complete +campaigns reached the 45-minute hosted job deadline without producing a report +([retained-view job](https://github.com/bsv-blockchain/ts-stack/actions/runs/36713747014/job/109883824573), +[sync job](https://github.com/bsv-blockchain/ts-stack/actions/runs/36713747014/job/109883824697)). +Other targets retain their respective 45-minute PR and 20-minute standalone +limits. This allowance changes only the job deadline: source scopes, test +selection, four mutation workers, six parallel jobs, individual mutant/test +deadlines and all quality ratchets are unchanged. A deadline cancellation is +not a completed report or a passing score. List and run targets locally: diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index d22c62d42..5abd93dcb 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -6,6 +6,7 @@ import test from 'node:test' import { REPOSITORY_ROOT } from './repository-health.mjs' const CI_PATH = join(REPOSITORY_ROOT, '.github/workflows/ci.yml') +const MUTATION_PATH = join(REPOSITORY_ROOT, '.github/workflows/mutation-tests.yml') const CONFORMANCE_PATH = join(REPOSITORY_ROOT, '.github/workflows/conformance.yml') const RUNTIME_PATH = join(REPOSITORY_ROOT, '.github/workflows/container-runtime-contract.yml') const WALLET_MOBILE_COVERAGE_PATH = join( @@ -23,6 +24,12 @@ function workflowJobBlocks(workflow) { })) } +function assertWalletMutationTimeout(job, defaultMinutes) { + const targets = '["wallet-retained-snapshot","wallet-snapshot-sync"]' + const expected = ` timeout-minutes: \${{ contains(fromJSON('${targets}'), matrix.target) && 90 || ${defaultMinutes} }}` + assert.equal(job.source.match(/^ timeout-minutes: .+$/m)?.[0], expected) +} + test('CI shares one audited build across coverage and browser consumer lanes', () => { const workflow = readFileSync(CI_PATH, 'utf8') @@ -121,7 +128,11 @@ test('CI bounds every job and allocates no runner for an empty infrastructure ma assert.ok(jobs.length > 0) for (const job of jobs) { - assert.match(job.source, /^ timeout-minutes: \d+$/m, `${job.name} must have a timeout`) + if (job.name === 'mutation-tests') { + assertWalletMutationTimeout(job, 45) + } else { + assert.match(job.source, /^ timeout-minutes: \d+$/m, `${job.name} must have a timeout`) + } } assert.match(workflow, /^ has-infra: \$\{\{ steps\.scope\.outputs\.has-infra \}\}$/m) assert.match( @@ -131,6 +142,25 @@ test('CI bounds every job and allocates no runner for an empty infrastructure ma assert.match(workflow, /\( "\$INFRA_RESULT" != "success" && "\$INFRA_RESULT" != "skipped" \)/) }) +test('wallet mutation allowances preserve other limits and complete campaign execution', () => { + for (const [path, defaultMinutes] of [ + [CI_PATH, 45], + [MUTATION_PATH, 20] + ]) { + const job = workflowJobBlocks(readFileSync(path, 'utf8')).find( + job => job.name === 'mutation-tests' + ) + assert.ok(job, path) + assertWalletMutationTimeout(job, defaultMinutes) + assert.match(job.source, /^ fail-fast: false$/m) + assert.match(job.source, /^ max-parallel: 6$/m) + assert.match( + job.source, + /^ run: node scripts\/mutation-testing\.mjs --target "\$\{\{ matrix\.target \}\}"$/m + ) + } +}) + test('specialized workflows are bounded and required conformance checks always run on PRs', () => { const conformance = readFileSync(CONFORMANCE_PATH, 'utf8') const runtime = readFileSync(RUNTIME_PATH, 'utf8') From 44ee31cc2b1a2db83147d88e6dbaaa76814e89bd Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 07:21:07 -0700 Subject: [PATCH 047/127] feat(wallet): persist bounded snapshot archive staging --- docs/guides/wallet-sync-reliability.md | 69 ++ docs/reference/package-api-migrations.md | 76 +-- governance/mutation-testing/policy.json | 10 + governance/mutation-testing/targets.mjs | 18 + governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 13 + packages/wallet/wallet-toolbox/CHANGELOG.md | 7 + packages/wallet/wallet-toolbox/README.md | 3 + packages/wallet/wallet-toolbox/package.json | 6 +- .../src/storage/schema/KnexMigrations.ts | 12 + .../schema/snapshotArchiveMigration.ts | 50 ++ .../KnexSnapshotArchiveStore.property.test.ts | 151 +++++ .../archive/KnexSnapshotArchiveStore.test.ts | 600 ++++++++++++++++++ .../archive/KnexSnapshotArchiveStore.ts | 430 +++++++++++++ .../test/storage/runSnapshotArchiveMysql.cjs | 73 +++ .../test/storage/snapshotArchiveCrash.cjs | 155 +++++ .../test/storage/snapshotArchiveMysql.cjs | 125 ++++ scripts/test-governance.test.mjs | 4 +- specs/wallet/sync-portability-program.md | 9 + 19 files changed, 1771 insertions(+), 44 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 501520cbd..1852b9035 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -336,6 +336,75 @@ relationships, restart at every durable boundary, repeated import, native browser/mobile evidence and wallet adoption. No partial archive or optimistic activation should be labeled complete while those gates remain outstanding. +## Shared SQL snapshot staging (unpublished internal component) + +The `2026-09-30-002 add snapshot archive staging` migration adds three auxiliary +SQL tables for the remote snapshot implementation in progress. It does not change +standard wallet tables, their indexes, legacy checkpoints or BRC-38/39 bytes. +Storage opening still does not run migrations implicitly. The tables do not enter +portable archives or ordinary synchronization. + +`storage/snapshot/archive/KnexSnapshotArchiveStore` is an internal persistence +component, not an authenticated remote export API. A capture owns an internal +writer token and records one original source view, schema, network and wallet +profile. Each bounded page, sequence receipt and quota charge commits atomically. +An exact retry repeats the receipt; changed bytes or metadata reject. A completed +capture becomes immutable and readable from another server connection. The +capture controller must validate complete source closure before sealing it; this +store alone does not validate wallet records or cryptographic proofs. + +The initial policy allows at most eight handles and 128 MiB of logical reserved +storage globally, one handle and 32 MiB per profile, 1 MiB per page, 1,000 rows per +page and 4,096 pages. Metadata is at most 64 KiB. A reservation includes encoded +metadata/payload bytes plus fixed header and page charges; it is not a measured +bound on SQL file size, transaction logs, temporary disk or process RSS. The +shared database clock controls expiry (five minutes by default, at most one +hour). These preliminary limits do not establish acceptance for larger wallets; +operator resource policy and measured storage costs remain required work. + +Partial, closing, expired or differently owned captures are unavailable to +readers. Cleanup first fences new writes, then deletes at most 32 exact page +keys per statement. The profile and global reservation remain occupied until all +pages are removed. Interrupted cleanup is resumable; concurrent closers release +capacity once. The current component requires its owning controller to invoke +cleanup/reaping. No unattended worker or public capability is installed by the +migration. + +SQLite tests cover 300 generated capture schedules, independent hash-chain +receipts, cross-profile reads and cleanup, exact limits, and failures between page +insertion and checkpoint update. A synthetic process fixture terminates the +writer at five durable boundaries: before data, after page insertion, after the +checkpoint write, after transaction commit and after sealing but before the +acknowledgement. Uncommitted pages roll back; committed pages retain exact retry +receipts; only sealed captures are readable. Isolated MySQL 8.4.11 also exercises +1 MiB pages, independent connections racing for one profile, concurrent cleanup, +rollback and partial DDL recovery. These results apply to those fixtures. +Deployed PXC, authenticated HTTP, complete source capture and the full #544 +program still require implementation or qualification. + +Run the synthetic process-termination fixture from the repository root on macOS +or Linux (Node 24 and the package build are required): + +```sh +pnpm --filter @bsv/wallet-toolbox test:snapshot-archive-crash +``` + +The fixture owns a fresh temporary SQLite database per phase, kills only its own +child writer, checks recovery through a new connection, and removes those files. +It prints one result record per phase. This is a storage persistence check; its +synthetic pages are not a BRC-38 archive or a funded wallet recovery. + +The MySQL fixture uses the local Docker Desktop `desktop-linux` context and the +already available MySQL image +`mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d`. +It creates a loopback-only disposable container with a 1 GiB memory limit, two +CPUs, 256 PIDs and a 512 MiB temporary data filesystem, then removes it. The +launcher neither pulls an image nor accepts an external database connection. + +```sh +pnpm --filter @bsv/wallet-toolbox test:snapshot-archive-mysql +``` + ## Reproducible validation The fixture uses 10,000 same-timestamp labels (including tombstones), 64 proofs diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index ae62d2ab2..407ef29e7 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; controller/semantic validation, authenticated integration and operator resource policy are still required. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; controller/semantic validation, authenticated integration and operator resource policy are still required. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 109e6c06d..29ac464b4 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -77,6 +77,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-snapshot-archive", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts", + "risk": "critical", + "boundary": "Wallet shared snapshot archive staging, immutable page receipts, profile isolation, quotas and physical cleanup accounting", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "overlay-linkage", "manifest": "packages/overlays/topics/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 1369df78a..3629471ac 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -262,6 +262,24 @@ export function buildMutationTargets(repositoryRoot) { } }) }, + 'wallet-snapshot-archive': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts', + mutate: [ + 'src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts', + 'src/storage/schema/snapshotArchiveMigration.ts' + ], + ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/archive/*.test.ts'], { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + }) + }, 'wallet-snapshot-sync': { packageDirectory: 'packages/wallet/wallet-toolbox', manifest: 'packages/wallet/wallet-toolbox/package.json', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 736f73d33..5250e20fc 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,8 +217,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; controller/semantic validation, authenticated integration and operator resource policy are still required. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index e4a1ec1c4..48be93843 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -486,6 +486,19 @@ "Every supported value round-trips to the identical deterministic byte sequence.", "Text and map keys remain normalized and canonical across independent encode and decode passes." ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet shared snapshot archive staging, immutable page receipts, profile isolation, quotas and physical cleanup accounting", + "target": "Persistent bounded archive staging across capture retries, incomplete captures, independent readers and expired/cancelled cleanup", + "invariants": [ + "A partial or expired capture is never readable as a completed archive.", + "Exact page retries preserve immutable bytes and the independent hash-chain receipt without double charging.", + "The authenticated profile cannot read or release another profile's archive.", + "Capacity remains reserved through interrupted cleanup and is released exactly once after every page is deleted." + ] } ], "exclusions": [ diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 99e168eb4..a71c1b995 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,13 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add shared SQL snapshot staging with profile-bound internal capture ownership, + immutable completed pages, exact replay receipts, explicit logical byte/page + reservations and resumable bounded cleanup. A second auxiliary migration + leaves standard tables, legacy sync checkpoints and wire formats unchanged. + This is an internal prerequisite: source capture/closure validation, authenticated + remote endpoints and larger-wallet resource policy remain under implementation. + - Integrate coherent SQL pages into ordinary local push, pull and backup, with a dedicated source reader and short, fair destination commits. Add resumable push progress/cancellation. Commit rows, normalized ID maps and durable cursors diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 45a6707a4..795909ebb 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -48,6 +48,9 @@ release. The [durable local SQL backup integration](#durable-local-sql-backup-in below adds bounded pages and uses a dedicated reader to preserve foreground capacity. IndexedDB and remote RPC do not expose retained views. See the [retained view contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#retained-local-sql-read-views-unpublished-candidate). +The SQL candidate also includes [bounded archive staging](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#shared-sql-snapshot-staging-unpublished-internal-component) +for the ongoing remote snapshot implementation. This internal component does not +add an authenticated export endpoint or a browser/mobile database adapter. The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 177388d7d..4074496c7 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", @@ -72,7 +72,9 @@ "build": "tsc --build", "prepublishOnly": "pnpm build", "doc": "ts2md", - "sync-versions": "node syncVersions.js" + "sync-versions": "node syncVersions.js", + "test:snapshot-archive-crash": "pnpm build && node test/storage/snapshotArchiveCrash.cjs", + "test:snapshot-archive-mysql": "pnpm build && node test/storage/runSnapshotArchiveMysql.cjs" }, "bugs": { "url": "https://github.com/bsv-blockchain/ts-stack/issues" diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index 854446add..d9b8ad711 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,8 @@ +import { + addSnapshotArchiveTables, + removeSnapshotArchiveTables, + SNAPSHOT_ARCHIVE_MIGRATION +} from './snapshotArchiveMigration' /* eslint-disable @typescript-eslint/no-unused-vars */ import { Knex } from 'knex' import { addSnapshotSyncTables, removeSnapshotSyncTables, SNAPSHOT_SYNC_MIGRATION } from './snapshotSyncMigration' @@ -12,6 +17,7 @@ import { LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS } from '../methods/managedChangePolicy' +export { SNAPSHOT_ARCHIVE_MIGRATION } from './snapshotArchiveMigration' export { SNAPSHOT_SYNC_MIGRATION } from './snapshotSyncMigration' export const SYNC_TRANSFER_MIGRATION = '2026-09-09-001 add bounded sync transfers' @@ -96,6 +102,12 @@ export class KnexMigrations implements MigrationSource { } } + migrations[SNAPSHOT_ARCHIVE_MIGRATION] = { + config: { transaction: true }, + up: addSnapshotArchiveTables, + down: removeSnapshotArchiveTables + } + migrations[SNAPSHOT_SYNC_MIGRATION] = { config: { transaction: true }, up: addSnapshotSyncTables, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveMigration.ts new file mode 100644 index 000000000..75236d674 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveMigration.ts @@ -0,0 +1,50 @@ +import type { Knex } from 'knex' + +export const SNAPSHOT_ARCHIVE_MIGRATION = '2026-09-30-002 add snapshot archive staging' + +/** Separate from standard wallet tables and legacy synchronization checkpoints. */ +export async function addSnapshotArchiveTables(knex: Knex): Promise { + if (!(await knex.schema.hasTable('snapshot_archive_capacity'))) { + await knex.schema.createTable('snapshot_archive_capacity', table => { + table.integer('id').primary() + table.integer('archives').notNullable() + table.bigInteger('reservedBytes').notNullable() + }) + } + await knex('snapshot_archive_capacity').insert({ id: 1, archives: 0, reservedBytes: 0 }).onConflict('id').ignore() + if (!(await knex.schema.hasTable('snapshot_archives'))) { + await knex.schema.createTable('snapshot_archives', table => { + table.string('archiveId', 64).primary() + table.string('identityKey', 130).notNullable().unique() + table.string('writerToken', 64).notNullable() + table.string('state', 16).notNullable() + table.text('binding', 'mediumtext').notNullable() + table.bigInteger('expiresAt').notNullable().index() + table.bigInteger('reservedBytes').notNullable() + table.bigInteger('usedBytes').notNullable() + table.integer('nextSequence').notNullable() + table.integer('tableIndex').notNullable() + table.bigInteger('rows').notNullable() + table.string('digest', 64).notNullable() + }) + } + if (!(await knex.schema.hasTable('snapshot_archive_pages'))) { + await knex.schema.createTable('snapshot_archive_pages', table => { + table.string('archiveId', 64).notNullable() + table.integer('sequence').notNullable() + table.string('tableName', 32).notNullable() + table.integer('rows').notNullable() + table.boolean('done').notNullable() + table.string('digest', 64).notNullable() + const mysql = String(knex.client.config.client).includes('mysql') + table.specificType('payload', mysql ? 'MEDIUMBLOB' : 'BLOB').notNullable() + table.primary(['archiveId', 'sequence']) + }) + } +} + +export async function removeSnapshotArchiveTables(knex: Knex): Promise { + await knex.schema.dropTableIfExists('snapshot_archive_pages') + await knex.schema.dropTableIfExists('snapshot_archives') + await knex.schema.dropTableIfExists('snapshot_archive_capacity') +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts new file mode 100644 index 000000000..a239b0faf --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts @@ -0,0 +1,151 @@ +import fc from 'fast-check' +import { createHash } from 'node:crypto' +import { knex } from 'knex' +import { addSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' +import { + KnexSnapshotArchiveStore, + snapshotArchiveTables, + type SnapshotArchiveBinding +} from './KnexSnapshotArchiveStore' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +const identity = '02' + '11'.repeat(32) +const other = '03' + '22'.repeat(32) +const date = new Date('2026-01-01T00:00:00.000Z') +const binding: SnapshotArchiveBinding = { + version: 1, + snapshotId: 'a'.repeat(64), + sourceSchema: 'property-v1', + sourceStorage: { + created_at: date, + updated_at: date, + storageIdentityKey: 'original', + storageName: 'source', + chain: 'test', + dbtype: 'SQLite', + maxOutputScript: 1024 + }, + user: { userId: 1, identityKey: identity, activeStorage: 'historical', created_at: date, updated_at: date } +} +const digest = (bytes: Uint8Array | string): string => createHash('sha256').update(bytes).digest('hex') + +// The model uses Node's independent hash implementation and only the persisted +// public receipts; it does not call the store's hashing/accounting helpers. +test('generated capture schedules preserve immutable receipts and reserve capacity through cancellation or expiry', async () => { + const db = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const store = new KnexSnapshotArchiveStore(db) + try { + await addSnapshotArchiveTables(db) + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + bytes: fc.uint8Array({ minLength: 1, maxLength: 96 }), + rows: fc.integer({ min: 0, max: 7 }), + replay: fc.boolean(), + reconnect: fc.boolean() + }), + { minLength: 13, maxLength: 13 } + ), + fc.integer({ min: 4097, max: 16000 }), + fc.integer({ min: -1, max: 13 }), + fc.boolean(), + async (pages, reservation, cancelAfter, expire) => { + let used = new TextEncoder().encode(JSON.stringify(binding)).length + 4096 + if (reservation < used) { + await expect(store.begin(binding, { maxBytes: reservation })).rejects.toThrow('metadata') + expect(await db('snapshot_archives')).toHaveLength(0) + return + } + const writer = await store.begin(binding, { maxBytes: reservation }) + let active = store + let expectedDigest = digest(JSON.stringify(binding)) + let rows = 0 + let accepted = 0 + try { + for (const [sequence, generated] of pages.entries()) { + if (cancelAfter === sequence) return + if (generated.reconnect) active = new KnexSnapshotArchiveStore(db) + const page = { + sequence, + table: snapshotArchiveTables[sequence], + rows: generated.rows, + done: true, + bytes: generated.bytes + } + const charge = generated.bytes.length + 512 + if (used + charge > reservation) { + const before = await db('snapshot_archives').first() + await expect(active.append(writer, page)).rejects.toThrow('reservation exhausted') + expect(await db('snapshot_archives').first()).toEqual(before) + return + } + await active.append(writer, page) + accepted++ + used += charge + rows += generated.rows + expectedDigest = digest( + JSON.stringify([expectedDigest, sequence, page.table, page.rows, true, digest(page.bytes)]) + ) + if (generated.replay) await active.append(writer, page) + const persisted = await db('snapshot_archives').first() + expect(persisted).toMatchObject({ + nextSequence: accepted, + tableIndex: accepted, + usedBytes: used, + rows, + digest: expectedDigest + }) + expect(await db('snapshot_archive_pages')).toHaveLength(accepted) + await expect(active.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') + } + if (cancelAfter === 13) return + const manifest = await active.seal(writer) + expect(manifest).toMatchObject({ pages: 13, rows, digest: expectedDigest, binding }) + await active.close(other, writer.archiveId) + for (const [sequence, generated] of pages.entries()) { + const received = await new KnexSnapshotArchiveStore(db).read(identity, writer.archiveId, sequence) + expect(received).toMatchObject({ + bytes: generated.bytes, + rows: generated.rows, + digest: digest(generated.bytes), + done: true, + table: snapshotArchiveTables[sequence] + }) + } + if (expire) { + await db('snapshot_archives').where({ archiveId: writer.archiveId }).update({ expiresAt: 0 }) + await expect(active.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') + await expect(active.read(identity, writer.archiveId, 0)).rejects.toThrow('unavailable') + } + } finally { + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ + archives: 1, + reservedBytes: reservation + }) + await new KnexSnapshotArchiveStore(db).close(identity, writer.archiveId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + } + } + ) + ) + } finally { + await db.destroy() + } +}, 60000) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts new file mode 100644 index 000000000..e2e1577ed --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -0,0 +1,600 @@ +import { WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { KnexMigrations } from '../../schema/KnexMigrations' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex, type Knex } from 'knex' +import { addSnapshotArchiveTables, removeSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' +import { + KnexSnapshotArchiveStore, + snapshotArchiveTables, + snapshotArchiveLimits, + type SnapshotArchiveBinding, + type SnapshotArchiveWriter +} from './KnexSnapshotArchiveStore' + +const identity = '02' + '11'.repeat(32) +const other = '03' + '22'.repeat(32) +const date = new Date('2026-01-01T00:00:00.000Z') +const binding: SnapshotArchiveBinding = { + version: 1, + snapshotId: 'a'.repeat(64), + sourceSchema: 'test-schema-v1', + sourceStorage: { + created_at: date, + updated_at: date, + storageIdentityKey: 'source', + storageName: 'original source', + chain: 'test', + dbtype: 'SQLite', + maxOutputScript: 1024 + }, + user: { userId: 7, identityKey: identity, activeStorage: 'historical primary', created_at: date, updated_at: date } +} +const bytes = new TextEncoder().encode('[]') +const databases: Knex[] = [] +const directories: string[] = [] + +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-archive-')) + directories.push(directory) + const open = () => { + const db = knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'archive.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 5000 + }) + databases.push(db) + return db + } + const db = open() + await db.raw('PRAGMA journal_mode = WAL') + await addSnapshotArchiveTables(db) + return { db, peer: open(), store: new KnexSnapshotArchiveStore(db) } +} + +async function complete(store: KnexSnapshotArchiveStore, writer: SnapshotArchiveWriter) { + for (const [sequence, table] of snapshotArchiveTables.entries()) { + await store.append(writer, { sequence, table, rows: 0, done: true, bytes }) + } + return await store.seal(writer) +} + +afterEach(async () => { + await Promise.all(databases.splice(0).map(db => db.destroy())) + await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +test('completed staging is immutable and readable from an independent server connection', async () => { + const { db, peer, store } = await fixture() + const writer = await store.begin(binding) + await expect(store.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') + await expect(store.seal(writer)).rejects.toThrow('unavailable') + await expect(new KnexSnapshotArchiveStore(peer).begin(binding)).rejects.toThrow('occupied') + const manifest = await complete(store, writer) + expect(manifest.pages).toBe(13) + expect(manifest.rows).toBe(0) + expect(manifest.binding).toEqual(binding) + expect(manifest).not.toHaveProperty('writerToken') + const secondServer = new KnexSnapshotArchiveStore(peer) + expect(await secondServer.inspect(identity, writer.archiveId)).toEqual(manifest) + expect(await secondServer.seal(writer)).toEqual(manifest) + for (const [sequence, table] of snapshotArchiveTables.entries()) { + const page = await secondServer.read(identity, writer.archiveId, sequence) + expect(page).toMatchObject({ sequence, table, rows: 0, done: true, bytes }) + page.bytes.fill(0) + expect((await store.read(identity, writer.archiveId, sequence)).bytes).toEqual(bytes) + } + await expect(secondServer.read(identity, writer.archiveId, 13)).rejects.toThrow('unavailable') + await expect( + secondServer.append(writer, { sequence: 13, table: 'syncStates', rows: 0, done: true, bytes }) + ).rejects.toThrow('unavailable') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ + archives: 1, + reservedBytes: snapshotArchiveLimits.archiveBytes + }) + await secondServer.close(identity, writer.archiveId) + await secondServer.close(identity, writer.archiveId) + await expect(store.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('mainnet source metadata survives sealing and cross-connection reads', async () => { + const { peer, store } = await fixture() + const mainnet: SnapshotArchiveBinding = { + ...binding, + sourceStorage: { ...binding.sourceStorage, chain: 'main' } + } + const writer = await store.begin(mainnet) + const manifest = await complete(store, writer) + const secondServer = new KnexSnapshotArchiveStore(peer) + expect(manifest.binding).toEqual(mainnet) + expect((await secondServer.inspect(identity, writer.archiveId)).binding).toEqual(mainnet) + expect((await secondServer.read(identity, writer.archiveId, 12)).table).toBe('syncStates') + await secondServer.close(identity, writer.archiveId) + await expect(store.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') +}) + +test('profile and internal capture ownership remain independent authorization boundaries', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + await expect( + store.append( + { ...writer, writerToken: '0'.repeat(64) }, + { sequence: 0, table: 'provenTxs', rows: 0, done: true, bytes } + ) + ).rejects.toThrow('unavailable') + await complete(store, writer) + await expect(store.inspect(other, writer.archiveId)).rejects.toThrow('unavailable') + await expect(store.read(other, writer.archiveId, 0)).rejects.toThrow('unavailable') + await store.close(other, writer.archiveId) + expect((await store.inspect(identity, writer.archiveId)).pages).toBe(13) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1 }) +}) + +test('lost page acknowledgements replay exactly, rejecting gaps and changed content or metadata', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + const first = { sequence: 0, table: 'provenTxs' as const, rows: 1, done: false, bytes: new Uint8Array([1, 2, 3]) } + await store.append(writer, first) + const before = await db('snapshot_archives').first() + await store.append(writer, first) + expect(await db('snapshot_archives').first()).toEqual(before) + expect(await db('snapshot_archive_pages')).toHaveLength(1) + for (const change of [{ bytes }, { rows: 2 }, { done: true }, { table: 'outputs' as const }, { sequence: 2 }]) { + await expect(store.append(writer, { ...first, ...change })).rejects.toThrow('unavailable') + } + expect(await db('snapshot_archives').first()).toEqual(before) + await expect(store.append(writer, { ...first, sequence: 1, table: 'outputs' })).rejects.toThrow('unavailable') + await store.append(writer, { ...first, sequence: 1, rows: 0, done: true }) + expect((await db('snapshot_archives').first()).tableIndex).toBe(1) +}) + +test('an interrupted atomic append preserves the prior cursor, accounting and pages', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + const before = await db('snapshot_archives').first() + await db.raw( + "CREATE TRIGGER archive_checkpoint_failure BEFORE UPDATE ON snapshot_archives WHEN NEW.nextSequence > OLD.nextSequence BEGIN SELECT RAISE(ABORT, 'synthetic checkpoint failure'); END" + ) + await expect(store.append(writer, { sequence: 0, table: 'provenTxs', rows: 0, done: true, bytes })).rejects.toThrow( + 'synthetic checkpoint failure' + ) + await db.raw('DROP TRIGGER archive_checkpoint_failure') + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archives').first()).toEqual(before) + await store.append(writer, { sequence: 0, table: 'provenTxs', rows: 0, done: true, bytes }) + expect(await db('snapshot_archive_pages')).toHaveLength(1) +}) + +test('quota exhaustion rejects before persistence and retains the original reservation', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding, { maxBytes: 8192 }) + const before = await db('snapshot_archives').first() + await expect( + store.append(writer, { sequence: 0, table: 'provenTxs', rows: 1, done: true, bytes: new Uint8Array(8192) }) + ).rejects.toThrow('reservation exhausted') + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archives').first()).toEqual(before) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 8192 }) + await store.close(identity, writer.archiveId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('independent profiles enforce both global byte and handle limits', async () => { + const { db, peer, store } = await fixture() + const owners: Array<{ identityKey: string; writer: SnapshotArchiveWriter }> = [] + for (let n = 0; n < 4; n++) { + const identityKey = '02' + n.toString(16).padStart(64, '0') + owners.push({ identityKey, writer: await store.begin({ ...binding, user: { ...binding.user, identityKey } }) }) + } + await expect(new KnexSnapshotArchiveStore(peer).begin(binding)).rejects.toThrow('occupied') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 4, reservedBytes: 128 * 1024 * 1024 }) + for (const owner of owners) await store.close(owner.identityKey, owner.writer.archiveId) + for (let n = 0; n < 8; n++) { + await store.begin( + { ...binding, user: { ...binding.user, identityKey: '02' + n.toString(16).padStart(64, '0') } }, + { maxBytes: 8192 } + ) + } + await expect(new KnexSnapshotArchiveStore(peer).begin(binding, { maxBytes: 8192 })).rejects.toThrow('occupied') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 8, reservedBytes: 65536 }) +}) + +test('expiry from an independent store never publishes partial staging and reclaims its reservation', async () => { + const { db, peer, store } = await fixture() + const writer = await store.begin(binding) + await store.append(writer, { sequence: 0, table: 'provenTxs', rows: 0, done: true, bytes }) + const restarted = new KnexSnapshotArchiveStore(peer) + await expect(restarted.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') + await db('snapshot_archives').where({ archiveId: writer.archiveId }).update({ expiresAt: 0 }) + await expect( + store.append(writer, { sequence: 1, table: 'provenTxReqs', rows: 0, done: true, bytes }) + ).rejects.toThrow('unavailable') + await expect(restarted.seal(writer)).rejects.toThrow('unavailable') + await restarted.reap() + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('interrupted bounded cleanup remains recoverable and does not release capacity early', async () => { + const { db, peer, store } = await fixture() + const writer = await store.begin(binding) + for (let sequence = 0; sequence < 70; sequence++) { + await store.append(writer, { sequence, table: 'provenTxs', rows: 1, done: false, bytes }) + } + const original = db.client.query.bind(db.client) + let deletions = 0 + const failure = new Error('synthetic deletion failure') + const intercept = jest.spyOn(db.client, 'query').mockImplementation(async (connection, query, ...rest) => { + if (typeof query !== 'string' && query.sql.startsWith('delete from `snapshot_archive_pages`')) { + if (++deletions === 2) throw failure + expect(query.bindings.length).toBeLessThanOrEqual(33) + } + return await original(connection, query, ...rest) + }) + await expect(store.close(identity, writer.archiveId)).rejects.toBe(failure) + intercept.mockRestore() + expect(await db('snapshot_archive_pages')).toHaveLength(38) + expect((await db('snapshot_archives').first()).state).toBe('closing') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ + archives: 1, + reservedBytes: snapshotArchiveLimits.archiveBytes + }) + const restarted = new KnexSnapshotArchiveStore(peer) + await expect(restarted.begin(binding)).rejects.toThrow('occupied') + await restarted.reap() + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await restarted.reap() + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('corrupted staged bytes are never returned as a verified page', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + await db('snapshot_archive_pages') + .where({ archiveId: writer.archiveId, sequence: 0 }) + .update({ payload: Buffer.from([0]) }) + await expect(store.read(identity, writer.archiveId, 0)).rejects.toThrow('unavailable') +}) + +test('staging migration recovers partial auxiliary DDL without resetting existing reservations', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + await addSnapshotArchiveTables(db) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1 }) + expect((await db('snapshot_archives').first()).archiveId).toBe(writer.archiveId) + await store.close(identity, writer.archiveId) + await db.schema.dropTable('snapshot_archive_pages') + await addSnapshotArchiveTables(db) + expect(await db.schema.hasTable('snapshot_archive_pages')).toBe(true) + await removeSnapshotArchiveTables(db) + for (const table of ['snapshot_archive_pages', 'snapshot_archives', 'snapshot_archive_capacity']) { + expect(await db.schema.hasTable(table)).toBe(false) + } +}) + +test('the exact page and reservation limits admit the boundary and reject the next byte', async () => { + const { db, store } = await fixture() + const payload = new Uint8Array(1024 * 1024).fill(7) + const exactBytes = new TextEncoder().encode(JSON.stringify(binding)).length + 4096 + 512 + payload.length + const writer = await store.begin(binding, { maxBytes: exactBytes }) + await store.append(writer, { sequence: 0, table: 'provenTxs', rows: 1000, done: false, bytes: payload }) + expect(Number((await db('snapshot_archives').first()).usedBytes)).toBe(exactBytes) + await expect(store.append(writer, { sequence: 1, table: 'provenTxs', rows: 1, done: false, bytes })).rejects.toThrow( + 'reservation exhausted' + ) + await store.close(identity, writer.archiveId) + const next = await store.begin(binding) + await expect( + store.append(next, { + sequence: 0, + table: 'provenTxs', + rows: 1, + done: true, + bytes: new Uint8Array(payload.length + 1) + }) + ).rejects.toThrow('page') + expect(await db('snapshot_archive_pages')).toHaveLength(0) +}) + +test('caller mutation cannot rebind a pending archive or replace pending page bytes', async () => { + const { db, store } = await fixture() + const mutable = structuredClone(binding) + const opening = store.begin(mutable) + mutable.user.identityKey = other + mutable.user.activeStorage = 'changed' + mutable.sourceStorage.storageIdentityKey = 'changed' + const writer = await opening + expect((await db('snapshot_archives').first()).identityKey).toBe(identity) + const payload = new Uint8Array([1, 2, 3]) + const append = store.append(writer, { sequence: 0, table: 'provenTxs', rows: 1, done: false, bytes: payload }) + payload.fill(0) + await append + expect(new Uint8Array((await db('snapshot_archive_pages').first()).payload)).toEqual(new Uint8Array([1, 2, 3])) + expect(JSON.parse((await db('snapshot_archives').first()).binding).user.activeStorage).toBe('historical primary') +}) + +test.each([0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, 3600001])( + 'invalid lifetime %s never reserves capacity', + async lifetimeMs => { + const { db, store } = await fixture() + await expect(store.begin(binding, { lifetimeMs })).rejects.toThrow('lifetimeMs') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + } +) + +test.each([0, 4096, 1.5, Number.NaN, 33554433])( + 'invalid byte reservation %s never persists a handle', + async maxBytes => { + const { db, store } = await fixture() + await expect(store.begin(binding, { maxBytes })).rejects.toThrow('maxBytes') + expect(await db('snapshot_archives')).toHaveLength(0) + } +) + +test('metadata limits and original schema/profile bindings reject malformed inputs', async () => { + const { db, store } = await fixture() + const variants = [ + { ...binding, version: 2 }, + { ...binding, snapshotId: '' }, + { ...binding, sourceSchema: '' }, + { ...binding, sourceSchema: 'x'.repeat(257) }, + { ...binding, sourceSchema: 1 }, + { ...binding, sourceStorage: { ...binding.sourceStorage, chain: 'other' } }, + { ...binding, sourceStorage: { ...binding.sourceStorage, storageIdentityKey: '' } }, + { ...binding, sourceStorage: { ...binding.sourceStorage, storageIdentityKey: 'x'.repeat(131) } }, + { ...binding, sourceStorage: { ...binding.sourceStorage, storageIdentityKey: 1 } }, + { ...binding, sourceStorage: { ...binding.sourceStorage, created_at: new Date(Number.NaN) } }, + { ...binding, user: { ...binding.user, updated_at: '2026-01-01' } }, + { ...binding, user: { ...binding.user, userId: 0 } }, + { ...binding, user: { ...binding.user, identityKey: 'invalid' } } + ] + for (const value of variants) + await expect(store.begin(value as SnapshotArchiveBinding)).rejects.toBeInstanceOf(WERR_INVALID_PARAMETER) + await expect( + store.begin({ ...binding, sourceStorage: { ...binding.sourceStorage, storageName: 'x'.repeat(65536) } }) + ).rejects.toThrow('metadata') + await expect(store.begin(binding, { maxBytes: 4097 })).rejects.toThrow('metadata') + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('invalid page shapes do not advance the capture', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + const first = { sequence: 0, table: 'provenTxs' as const, rows: 1, done: false, bytes } + const variants = [ + { sequence: -1 }, + { sequence: 4096 }, + { rows: -1 }, + { rows: 1001 }, + { rows: 0 }, + { done: undefined }, + { bytes: [] }, + { bytes: new Uint8Array() }, + { table: 'users' } + ] + for (const variant of variants) + await expect(store.append(writer, { ...first, ...variant } as typeof first)).rejects.toBeInstanceOf( + WERR_INVALID_PARAMETER + ) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect((await db('snapshot_archives').first()).nextSequence).toBe(0) + for (const bad of ['', 'a'.repeat(63), 'g'.repeat(64)]) { + await expect(store.inspect(identity, bad)).rejects.toThrow('archiveId') + await expect(store.close(identity, bad)).rejects.toThrow('archiveId') + await expect(store.seal({ ...writer, writerToken: bad })).rejects.toThrow('writerToken') + } + await expect(store.inspect('invalid', writer.archiveId)).rejects.toThrow('identityKey') +}) + +test('missing capacity metadata fails closed before creating a capture', async () => { + const { db, store } = await fixture() + await db('snapshot_archive_capacity').delete() + await expect(store.begin(binding)).rejects.toThrow('schema is unavailable') + expect(await db('snapshot_archives')).toHaveLength(0) +}) + +test('an expiry or close between header and payload reads cannot return a newly stale page', async () => { + const { db, peer, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + const original = db.client.query.bind(db.client) + let intercepted = false + const intercept = jest.spyOn(db.client, 'query').mockImplementation(async (connection, query, ...rest) => { + if (!intercepted && typeof query !== 'string' && query.sql.startsWith('select * from `snapshot_archive_pages`')) { + intercepted = true + await peer('snapshot_archives').where({ archiveId: writer.archiveId }).update({ expiresAt: 0 }) + } + return await original(connection, query, ...rest) + }) + await expect(store.read(identity, writer.archiveId, 0)).rejects.toThrow('unavailable') + intercept.mockRestore() + expect(intercepted).toBe(true) +}) + +test('missing persisted page content cannot become an acknowledged replay or read', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + await db('snapshot_archive_pages').where({ archiveId: writer.archiveId, sequence: 0 }).delete() + await expect(store.append(writer, { sequence: 0, table: 'provenTxs', rows: 0, done: true, bytes })).rejects.toThrow( + 'unavailable' + ) + await expect(store.read(identity, writer.archiveId, 0)).rejects.toThrow('unavailable') +}) + +test('capture cannot append or seal once cleanup has begun', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + await db('snapshot_archives').where({ archiveId: writer.archiveId }).update({ state: 'closing' }) + await expect(store.append(writer, { sequence: 0, table: 'provenTxs', rows: 0, done: true, bytes })).rejects.toThrow( + 'unavailable' + ) + await expect(store.seal(writer)).rejects.toThrow('unavailable') + await store.reap() + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('the MySQL adapter uses the shared database clock and a blob type admitting bounded pages', async () => { + const { db, store } = await fixture() + const raw = jest.fn(async () => [[{ now: '1790770000000' }]]) + const readClock = Reflect.get(store, 'now') as (database: unknown) => Promise + expect(await readClock.call(store, { client: { config: { client: 'mysql2' } }, raw })).toBe(1790770000000) + expect(raw).toHaveBeenCalledWith('SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000) AS now') + await db.schema.dropTable('snapshot_archive_pages') + const original = db.client.config.client + try { + // Keep the SQLite executor while checking the dialect-dependent declared + // type. Native MySQL qualification independently executes the emitted DDL. + db.client.config.client = 'mysql2' + await addSnapshotArchiveTables(db) + } finally { + db.client.config.client = original + } + const columns: Array<{ name: string; type: string }> = await db.raw('PRAGMA table_info(snapshot_archive_pages)') + expect(columns.find(column => column.name === 'payload')?.type.toUpperCase()).toBe('MEDIUMBLOB') +}) + +test('parallel profile captures never overwrite, seal, or collect each other', async () => { + const { db, store } = await fixture() + const second = await store.begin({ ...binding, user: { ...binding.user, identityKey: other } }, { maxBytes: 16384 }) + const secondBefore = await db('snapshot_archives').where({ archiveId: second.archiveId }).first() + const first = await store.begin(binding, { maxBytes: 32768 }) + await complete(store, first) + expect(await db('snapshot_archives').where({ archiveId: second.archiveId }).first()).toEqual(secondBefore) + const secondManifest = await complete(store, second) + const secondPages = await db('snapshot_archive_pages').where({ archiveId: second.archiveId }).orderBy('sequence') + const projections: string[] = [] + const observe = (query: { sql: string }): void => { + if (query.sql.startsWith('select') && query.sql.includes('snapshot_archive_pages')) projections.push(query.sql) + } + db.on('query', observe) + await Promise.all([store.close(identity, first.archiveId), store.close(identity, first.archiveId)]) + db.removeListener('query', observe) + expect(projections.length).toBeGreaterThan(0) + expect(projections.every(query => query.startsWith('select `sequence` from'))).toBe(true) + expect(await store.inspect(other, second.archiveId)).toEqual(secondManifest) + expect(await db('snapshot_archive_pages').where({ archiveId: second.archiveId }).orderBy('sequence')).toEqual( + secondPages + ) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 16384 }) + await store.close(other, second.archiveId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('valid identifier and metadata boundary values remain admitted', async () => { + const { store } = await fixture() + for (const sourceSchema of ['x', 'x'.repeat(256)]) { + for (const storageIdentityKey of ['x', 'x'.repeat(130)]) { + const writer = await store.begin({ + ...binding, + sourceSchema, + sourceStorage: { ...binding.sourceStorage, storageIdentityKey } + }) + await store.close(identity, writer.archiveId) + } + } + const base = new TextEncoder().encode(JSON.stringify(binding)).length + const large = { + ...binding, + sourceStorage: { + ...binding.sourceStorage, + storageName: 'x'.repeat(65536 - base + binding.sourceStorage.storageName.length) + } + } + expect(new TextEncoder().encode(JSON.stringify(large)).length).toBe(65536) + const writer = await store.begin(large, { maxBytes: 65536 + 4096 }) + await store.close(identity, writer.archiveId) +}) + +test('identifiers reject prefixes, suffixes and non-string coercions before SQL', async () => { + const { store } = await fixture() + const writer = await store.begin(binding) + for (const bad of ['x' + writer.archiveId, writer.archiveId + 'x', { toString: () => writer.archiveId }]) { + await expect(store.inspect(identity, bad as string)).rejects.toBeInstanceOf(WERR_INVALID_PARAMETER) + await expect(store.seal({ ...writer, archiveId: bad as string })).rejects.toBeInstanceOf(WERR_INVALID_PARAMETER) + } + for (const bad of ['x' + identity, identity + 'x', { toString: () => identity }]) { + await expect(store.inspect(bad as string, writer.archiveId)).rejects.toBeInstanceOf(WERR_INVALID_PARAMETER) + } + for (const malformed of [undefined, { ...binding, user: undefined }, { ...binding, sourceStorage: undefined }]) { + await expect(store.begin(malformed as unknown as SnapshotArchiveBinding)).rejects.toBeInstanceOf( + WERR_INVALID_PARAMETER + ) + } +}) + +test('expiry is inclusive at the shared-clock boundary for both writer and reader', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + const row = await db('snapshot_archives').first() + const readClock = jest + .spyOn(store as unknown as { now: () => Promise }, 'now') + .mockResolvedValue(Number(row.expiresAt)) + try { + await expect(store.seal(writer)).rejects.toThrow('unavailable') + await expect(store.read(identity, writer.archiveId, 0)).rejects.toThrow('unavailable') + } finally { + readClock.mockRestore() + } +}) + +test('only acknowledged sequence positions are readable, even if an unacknowledged page exists', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + const page = await db('snapshot_archive_pages').where({ archiveId: writer.archiveId, sequence: 0 }).first() + await db('snapshot_archive_pages').insert({ ...page, sequence: 13 }) + await expect(store.read(identity, writer.archiveId, 13)).rejects.toThrow('unavailable') + for (const sequence of [-1, 4096]) { + await expect(store.read(identity, writer.archiveId, sequence)).rejects.toBeInstanceOf(WERR_INVALID_PARAMETER) + await expect( + store.append(writer, { sequence, table: 'provenTxs', rows: 0, done: true, bytes }) + ).rejects.toBeInstanceOf(WERR_INVALID_PARAMETER) + } +}) + +test('the auxiliary migration is registered after the durable sync schema', async () => { + const migrations = new KnexMigrations('test', 'source', 'source', 1024) + expect(await migrations.getLatestMigration()).toBe('2026-09-30-002 add snapshot archive staging') +}) + +test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { + const mysql = knex({ client: 'mysql2' }) + const commands: string[] = [] + const fake = Object.assign(() => ({ insert: () => ({ onConflict: () => ({ ignore: async () => undefined }) }) }), { + client: mysql.client, + schema: { + hasTable: async () => false, + createTable: async (name: string, define: (table: Knex.CreateTableBuilder) => void) => { + commands.push( + ...mysql.schema + .createTable(name, define) + .toSQL() + .map(query => query.sql) + ) + } + } + }) + try { + await addSnapshotArchiveTables(fake as unknown as Knex) + expect(commands.find(sql => sql.startsWith('create table `snapshot_archives`'))).toContain( + '`binding` mediumtext not null' + ) + expect(commands.find(sql => sql.startsWith('create table `snapshot_archive_pages`'))).toContain( + '`payload` MEDIUMBLOB not null' + ) + } finally { + await mysql.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts new file mode 100644 index 000000000..60633088c --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts @@ -0,0 +1,430 @@ +import { Hash, Random, Utils } from '@bsv/sdk' +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import type { TableSettings, TableUser } from '../../schema/tables' +import type { WalletSnapshotTable } from '../WalletReadSnapshot' +import { runInSeries } from '../../../utility/runInSeries' + +/** Persistent transport staging; these auxiliary records never enter BRC-38. */ +export const snapshotArchiveTables = Object.freeze([ + 'provenTxs', + 'provenTxReqs', + 'outputBaskets', + 'transactions', + 'commissions', + 'outputs', + 'outputTags', + 'outputTagMaps', + 'txLabels', + 'txLabelMaps', + 'certificates', + 'certificateFields', + 'syncStates' +] as const satisfies readonly WalletSnapshotTable[]) + +export const snapshotArchiveLimits = Object.freeze({ + archives: 8, + totalBytes: 128 * 1024 * 1024, + archiveBytes: 32 * 1024 * 1024, + pageBytes: 1024 * 1024, + pages: 4096, + rowsPerPage: 1000, + lifetimeMs: 3600000, + bindingBytes: 65536, + headerCharge: 4096, + pageCharge: 512 +}) + +export interface SnapshotArchiveBinding { + version: 1 + /** The original retained source view, never a replacement replica checkpoint. */ + snapshotId: string + sourceStorage: TableSettings + sourceSchema: string + user: TableUser +} + +export interface SnapshotArchiveWriter { + archiveId: string + /** Internal capture ownership only. Never return this token to an RPC caller. */ + writerToken: string +} + +export interface SnapshotArchiveManifest { + version: 1 + archiveId: string + binding: SnapshotArchiveBinding + expiresAt: number + pages: number + rows: number + digest: string +} + +export interface SnapshotArchivePage { + sequence: number + table: WalletSnapshotTable + rows: number + done: boolean + bytes: Uint8Array + digest: string +} + +interface ArchiveRow { + archiveId: string + identityKey: string + writerToken: string + state: 'building' | 'ready' | 'closing' + binding: string + expiresAt: number | string + reservedBytes: number | string + usedBytes: number | string + nextSequence: number + tableIndex: number + rows: number | string + digest: string +} + +interface PageRow { + archiveId: string + sequence: number + tableName: WalletSnapshotTable + rows: number + done: number | boolean + payload: Uint8Array + digest: string +} + +function integer(value: number, min: number, max: number, name: string): number { + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new WERR_INVALID_PARAMETER(name, `an integer from ${min} to ${max}`) + } + return value +} + +function identifier(value: string, name: string): void { + if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) { + throw new WERR_INVALID_PARAMETER(name, 'a version-one snapshot archive identifier') + } +} + +function identity(value: string): void { + if (typeof value !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(value)) { + throw new WERR_INVALID_PARAMETER('identityKey', 'a compressed public identity key') + } +} + +function validDate(value: Date): boolean { + try { + return Number.isFinite(Date.prototype.getTime.call(value)) + } catch { + return false + } +} + +function hash(bytes: Uint8Array): string { + return Utils.toHex(Hash.sha256(Array.from(bytes))) +} + +function unavailable(): WERR_INVALID_OPERATION { + return new WERR_INVALID_OPERATION('Snapshot archive is unavailable') +} + +/** + * Shared SQL staging with a fixed global reservation and at most one archive per + * profile. Only auxiliary rows take the capacity lock; no source wallet row is + * acquired under it. Capacity remains reserved until every page is deleted. + */ +export class KnexSnapshotArchiveStore { + constructor(private readonly knex: Knex) {} + + private async now(k: Knex): Promise { + const mysql = String(k.client.config.client).includes('mysql') + if (mysql) { + const [rows]: Array> = await k.raw( + 'SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000) AS now' + ) + return Number(rows[0].now) + } + const rows: Array<{ now: number }> = await k.raw( + "SELECT CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER) AS now" + ) + return rows[0].now + } + + private async capacity(k: Knex): Promise<{ archives: number; reservedBytes: number | string }> { + // A harmless write acquires SQLite's writer reservation before any reads. + // MySQL takes this exact row lock, shared by all staging state mutations. + await k('snapshot_archive_capacity').where({ id: 1 }).update({ id: 1 }) + const row = await k('snapshot_archive_capacity').where({ id: 1 }).first() + if (row === undefined) throw new WERR_INVALID_OPERATION('Snapshot archive schema is unavailable') + return row + } + + async begin( + binding: SnapshotArchiveBinding, + options: { maxBytes?: number; lifetimeMs?: number } = {} + ): Promise { + const reservedBytes = integer( + options.maxBytes ?? snapshotArchiveLimits.archiveBytes, + snapshotArchiveLimits.headerCharge + 1, + snapshotArchiveLimits.archiveBytes, + 'maxBytes' + ) + const lifetimeMs = integer(options.lifetimeMs ?? 300000, 1, snapshotArchiveLimits.lifetimeMs, 'lifetimeMs') + identity(binding?.user?.identityKey) + identifier(binding?.snapshotId, 'snapshotId') + if ( + binding.version !== 1 || + !['main', 'test'].includes(binding.sourceStorage?.chain) || + typeof binding.sourceSchema !== 'string' || + binding.sourceSchema.length < 1 || + binding.sourceSchema.length > 256 || + typeof binding.sourceStorage.storageIdentityKey !== 'string' || + binding.sourceStorage.storageIdentityKey.length < 1 || + binding.sourceStorage.storageIdentityKey.length > 130 + ) { + throw new WERR_INVALID_PARAMETER('binding', 'a version-one source/profile binding') + } + integer(binding.user.userId, 1, Number.MAX_SAFE_INTEGER, 'userId') + for (const row of [binding.sourceStorage, binding.user]) { + for (const value of [row.created_at, row.updated_at]) { + if (!validDate(value)) { + throw new WERR_INVALID_PARAMETER('binding', 'valid source and profile dates') + } + } + } + const identityKey = binding.user.identityKey + const encoded = JSON.stringify(binding) + const bytes = new TextEncoder().encode(encoded) + const usedBytes = bytes.length + snapshotArchiveLimits.headerCharge + if (bytes.length > snapshotArchiveLimits.bindingBytes || usedBytes > reservedBytes) { + throw new SnapshotResourceLimitError('Snapshot archive metadata exceeds its reservation') + } + const writer = { archiveId: Utils.toHex(Random(32)), writerToken: Utils.toHex(Random(32)) } + await this.knex.transaction(async trx => { + const capacity = await this.capacity(trx) + const occupied = await trx('snapshot_archives').where({ identityKey }).first('archiveId') + if ( + occupied !== undefined || + capacity.archives >= snapshotArchiveLimits.archives || + Number(capacity.reservedBytes) + reservedBytes > snapshotArchiveLimits.totalBytes + ) { + throw new SnapshotResourceLimitError('Snapshot archive capacity is occupied') + } + await trx('snapshot_archives').insert({ + ...writer, + identityKey, + state: 'building', + binding: encoded, + expiresAt: (await this.now(trx)) + lifetimeMs, + reservedBytes, + usedBytes, + nextSequence: 0, + tableIndex: 0, + rows: 0, + digest: hash(bytes) + }) + await trx('snapshot_archive_capacity') + .where({ id: 1 }) + .update({ + archives: capacity.archives + 1, + reservedBytes: Number(capacity.reservedBytes) + reservedBytes + }) + }) + return writer + } + + private async ownedWriter(k: Knex, writer: SnapshotArchiveWriter): Promise { + identifier(writer.archiveId, 'archiveId') + identifier(writer.writerToken, 'writerToken') + const row: ArchiveRow | undefined = await k('snapshot_archives').where(writer).first() + if (row === undefined || row.state === 'closing' || Number(row.expiresAt) <= (await this.now(k))) + throw unavailable() + return row + } + + async append(writer: SnapshotArchiveWriter, page: Omit): Promise { + integer(page.sequence, 0, snapshotArchiveLimits.pages - 1, 'sequence') + integer(page.rows, 0, snapshotArchiveLimits.rowsPerPage, 'rows') + if ( + !snapshotArchiveTables.includes(page.table) || + typeof page.done !== 'boolean' || + !(page.bytes instanceof Uint8Array) || + page.bytes.length < 1 || + page.bytes.length > snapshotArchiveLimits.pageBytes || + (page.rows === 0 && !page.done) + ) + throw new WERR_INVALID_PARAMETER('page', 'a bounded snapshot archive page') + // Detach before taking an asynchronous lock, including caller-owned typed bytes. + const owner = { archiveId: writer.archiveId, writerToken: writer.writerToken } + const input = { ...page, bytes: new Uint8Array(page.bytes) } + const digest = hash(input.bytes) + await this.knex.transaction(async trx => { + await this.capacity(trx) + const row = await this.ownedWriter(trx, owner) + if (input.sequence < row.nextSequence) { + const prior: PageRow | undefined = await trx('snapshot_archive_pages') + .where({ archiveId: row.archiveId, sequence: input.sequence }) + .first() + if ( + prior === undefined || + prior.digest !== digest || + prior.tableName !== input.table || + prior.rows !== input.rows || + Boolean(prior.done) !== input.done + ) + throw unavailable() + return + } + if ( + row.state !== 'building' || + input.sequence !== row.nextSequence || + input.table !== snapshotArchiveTables[row.tableIndex] + ) + throw unavailable() + const usedBytes = Number(row.usedBytes) + input.bytes.length + snapshotArchiveLimits.pageCharge + if (usedBytes > Number(row.reservedBytes)) + throw new SnapshotResourceLimitError('Snapshot archive reservation exhausted') + await trx('snapshot_archive_pages').insert({ + archiveId: row.archiveId, + sequence: input.sequence, + tableName: input.table, + rows: input.rows, + done: input.done, + digest, + payload: Buffer.from(input.bytes) + }) + const receipt = new TextEncoder().encode( + JSON.stringify([row.digest, input.sequence, input.table, input.rows, input.done, digest]) + ) + await trx('snapshot_archives') + .where({ archiveId: row.archiveId }) + .update({ + usedBytes, + nextSequence: row.nextSequence + 1, + tableIndex: row.tableIndex + Number(input.done), + rows: Number(row.rows) + input.rows, + digest: hash(receipt) + }) + }) + } + + private manifest(row: ArchiveRow): SnapshotArchiveManifest { + const binding = JSON.parse(row.binding) as SnapshotArchiveBinding + for (const metadata of [binding.sourceStorage, binding.user]) { + metadata.created_at = new Date(String(metadata.created_at)) + metadata.updated_at = new Date(String(metadata.updated_at)) + } + return { + version: 1, + archiveId: row.archiveId, + binding, + expiresAt: Number(row.expiresAt), + pages: row.nextSequence, + rows: Number(row.rows), + digest: row.digest + } + } + + /** The capture controller must validate the complete source closure before sealing. */ + async seal(writer: SnapshotArchiveWriter): Promise { + const owner = { archiveId: writer.archiveId, writerToken: writer.writerToken } + return await this.knex.transaction(async trx => { + await this.capacity(trx) + const row = await this.ownedWriter(trx, owner) + if (row.tableIndex !== snapshotArchiveTables.length) throw unavailable() + await trx('snapshot_archives').where({ archiveId: row.archiveId }).update({ state: 'ready' }) + return this.manifest(row) + }) + } + + private async ready(identityKey: string, archiveId: string): Promise { + identity(identityKey) + identifier(archiveId, 'archiveId') + const row: ArchiveRow | undefined = await this.knex('snapshot_archives') + .where({ archiveId, identityKey, state: 'ready' }) + .first() + if (row === undefined || Number(row.expiresAt) <= (await this.now(this.knex))) throw unavailable() + return row + } + + async inspect(identityKey: string, archiveId: string): Promise { + return this.manifest(await this.ready(identityKey, archiveId)) + } + + async read(identityKey: string, archiveId: string, sequence: number): Promise { + integer(sequence, 0, snapshotArchiveLimits.pages - 1, 'sequence') + const header = await this.ready(identityKey, archiveId) + if (sequence >= header.nextSequence) throw unavailable() + const page: PageRow | undefined = await this.knex('snapshot_archive_pages').where({ archiveId, sequence }).first() + if (page === undefined || hash(page.payload) !== page.digest) throw unavailable() + // Release/expiry during I/O cannot return a newly stale page. + await this.ready(identityKey, archiveId) + return { + sequence, + table: page.tableName, + rows: page.rows, + done: Boolean(page.done), + digest: page.digest, + bytes: new Uint8Array(page.payload) + } + } + + async close(identityKey: string, archiveId: string): Promise { + identity(identityKey) + identifier(archiveId, 'archiveId') + const found = await this.knex.transaction(async trx => { + await this.capacity(trx) + const row = await trx('snapshot_archives').where({ archiveId, identityKey }).first('archiveId') + if (row === undefined) return false + await trx('snapshot_archives').where({ archiveId, identityKey }).update({ state: 'closing' }) + return true + }) + if (!found) return + // Bounded exact-key deletes do not hold source wallet locks or release the + // capacity reservation early. A crash or concurrent closer can resume them. + let hasPages = true + while (hasPages) { + const rows: Array<{ sequence: number }> = await this.knex('snapshot_archive_pages') + .select('sequence') + .where({ archiveId }) + .orderBy('sequence') + .limit(32) + hasPages = rows.length > 0 + if (hasPages) + await this.knex('snapshot_archive_pages') + .where({ archiveId }) + .whereIn( + 'sequence', + rows.map(row => row.sequence) + ) + .delete() + } + await this.knex.transaction(async trx => { + const capacity = await this.capacity(trx) + const row: ArchiveRow | undefined = await trx('snapshot_archives') + .where({ archiveId, identityKey, state: 'closing' }) + .first() + if (row === undefined) return + await trx('snapshot_archives').where({ archiveId }).delete() + await trx('snapshot_archive_capacity') + .where({ id: 1 }) + .update({ + archives: capacity.archives - 1, + reservedBytes: Number(capacity.reservedBytes) - Number(row.reservedBytes) + }) + }) + } + + /** Recover expired or interrupted cleanup without making incomplete captures readable. */ + async reap(): Promise { + const rows: Array<{ archiveId: string; identityKey: string }> = await this.knex('snapshot_archives') + .select('archiveId', 'identityKey') + .where('expiresAt', '<=', await this.now(this.knex)) + .orWhere({ state: 'closing' }) + .orderBy('archiveId') + .limit(snapshotArchiveLimits.archives) + await runInSeries(rows, row => this.close(row.identityKey, row.archiveId)) + } +} diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs new file mode 100644 index 000000000..30f57bd7e --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs @@ -0,0 +1,73 @@ +// Disposable, loopback-only synthetic MySQL qualification. Never targets an +// operator-supplied database, retains a volume, pulls an image, or builds one. +const { execFileSync } = require('node:child_process') +const { randomUUID } = require('node:crypto') +const { join } = require('node:path') +const assert = require('node:assert/strict') +const image = 'mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d' +const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +async function main() { + assert.equal(docker('context', 'show'), 'desktop-linux', 'This fixture requires the local Docker Desktop context') + docker('image', 'inspect', image) + const name = 'ts569-durable-' + randomUUID().slice(0, 8) + const id = docker( + 'run', + '--pull=never', + '--detach', + '--name', + name, + '--label', + 'network-ops.fixture=ts-stack-544-durable', + '--memory', + '1g', + '--cpus', + '2', + '--pids-limit', + '256', + '--tmpfs', + '/var/lib/mysql:rw,nosuid,nodev,size=512m', + '--env', + 'MYSQL_ROOT_PASSWORD=synthetic-snapshot-fixture', + '--env', + 'MYSQL_DATABASE=ts569_snapshot', + '--publish', + '127.0.0.1::3306', + image + ) + try { + const deadline = Date.now() + 60000 + for (;;) { + try { + // TCP specifically excludes the entrypoint's temporary socket-only server. + docker( + 'exec', + id, + 'mysqladmin', + '--protocol=tcp', + '--host=127.0.0.1', + '--user=root', + '--password=synthetic-snapshot-fixture', + 'ping' + ) + break + } catch (error) { + if (Date.now() >= deadline) throw error + await new Promise(resolve => setTimeout(resolve, 500)) + } + } + const result = execFileSync(process.execPath, [join(__dirname, 'snapshotArchiveMysql.cjs')], { + encoding: 'utf8', + env: { ...process.env, TS_STACK_SNAPSHOT_CONTAINER: name, TS_STACK_SNAPSHOT_CONTAINER_ID: id }, + timeout: 60000, + stdio: ['ignore', 'pipe', 'pipe'] + }) + process.stdout.write(result) + } finally { + docker('rm', '--force', id) + assert.equal(docker('ps', '-aq', '--filter', 'id=' + id), '') + } +} +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs new file mode 100644 index 000000000..a40097540 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs @@ -0,0 +1,155 @@ +// Synthetic SQLite process-termination qualification. Run after pnpm build. +// Each child owns a fresh temporary database; no wallet or service is contacted. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { spawn } = require('node:child_process') +const { knex } = require('knex') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + KnexSnapshotArchiveStore, + snapshotArchiveTables +} = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveStore.js') +const { addSnapshotArchiveTables } = require('../../out/src/storage/schema/snapshotArchiveMigration.js') +const identity = '02' + '11'.repeat(32), + date = new Date('2026-01-01T00:00:00.000Z') +const binding = { + version: 1, + snapshotId: 'a'.repeat(64), + sourceSchema: 'synthetic-v1', + sourceStorage: { + created_at: date, + updated_at: date, + storageIdentityKey: 'source', + storageName: 'source', + chain: 'test', + dbtype: 'SQLite', + maxOutputScript: 1024 + }, + user: { created_at: date, updated_at: date, userId: 7, identityKey: identity, activeStorage: 'source' } +} +const phases = [ + 'after-begin', + 'after-page-insert', + 'after-page-checkpoint', + 'after-append-commit', + 'after-seal-before-ack' +] +const open = directory => + knex({ + client: 'better-sqlite3', + connection: { filename: path.join(directory, 'archive.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) +async function child(directory, phase) { + assert(phases.includes(phase)) + const db = open(directory) + await db.raw('PRAGMA journal_mode = WAL') + await addSnapshotArchiveTables(db) + const store = new KnexSnapshotArchiveStore(db) + const writer = await store.begin(binding) + fs.writeFileSync(path.join(directory, 'writer.json'), JSON.stringify(writer), { mode: 0o600 }) + const park = () => { + fs.writeFileSync(path.join(directory, 'boundary'), phase) + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0) + } + if (phase === 'after-begin') park() + db.on('query-response', (_result, query) => { + if (phase === 'after-page-insert' && query.sql.startsWith('insert into `snapshot_archive_pages`')) park() + if ( + phase === 'after-page-checkpoint' && + query.sql.startsWith('update `snapshot_archives`') && + query.sql.includes('`nextSequence`') + ) + park() + }) + await runInSeries(snapshotArchiveTables.entries(), async ([sequence, table]) => { + await store.append(writer, { sequence, table, rows: 0, done: true, bytes: new Uint8Array([91, 93]) }) + if (phase === 'after-append-commit') park() + }) + await store.seal(writer) + if (phase === 'after-seal-before-ack') park() + throw new Error('Expected boundary was not reached') +} +async function parent() { + await runInSeries(phases, async phase => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ts569-archive-crash-')) + let processHandle, database, exited + try { + processHandle = spawn(process.execPath, [__filename, 'child', directory, phase], { + env: process.env, + stdio: ['ignore', 'ignore', 'pipe'] + }) + let error = '' + processHandle.stderr.on('data', chunk => { + error = (error + chunk.toString()).slice(-65536) + }) + exited = new Promise((resolve, reject) => { + processHandle.once('exit', (code, signal) => resolve({ code, signal })) + processHandle.once('error', reject) + }) + const deadline = Date.now() + 15000 + let reachedBoundary = fs.existsSync(path.join(directory, 'boundary')) + while (!reachedBoundary) { + if (processHandle.exitCode !== null || processHandle.signalCode !== null) + throw new Error(error || 'Child stopped before boundary') + assert(Date.now() < deadline, 'Boundary deadline: ' + phase) + await new Promise(resolve => setTimeout(resolve, 20)) + reachedBoundary = fs.existsSync(path.join(directory, 'boundary')) + } + assert.equal(fs.readFileSync(path.join(directory, 'boundary'), 'utf8'), phase) + processHandle.kill('SIGKILL') + const outcome = await exited + assert.equal(outcome.signal, 'SIGKILL') + database = open(directory) + const store = new KnexSnapshotArchiveStore(database) + const writer = JSON.parse(fs.readFileSync(path.join(directory, 'writer.json'), 'utf8')) + const before = await database('snapshot_archives').where({ archiveId: writer.archiveId }).first() + const ready = phase === 'after-seal-before-ack' + if (ready) { + assert.equal((await store.inspect(identity, writer.archiveId)).pages, 13) + assert.equal((await store.read(identity, writer.archiveId, 12)).table, 'syncStates') + } else await assert.rejects(store.inspect(identity, writer.archiveId), /unavailable/) + const committed = phase === 'after-append-commit' ? 1 : ready ? 13 : 0 + assert.equal(before.nextSequence, committed) + assert.equal(Number((await database('snapshot_archive_pages').count({ count: '*' }))[0].count), committed) + if (phase === 'after-append-commit') { + await store.append(writer, { + sequence: 0, + table: 'provenTxs', + rows: 0, + done: true, + bytes: new Uint8Array([91, 93]) + }) + assert.equal((await database('snapshot_archives').first()).nextSequence, 1) + } + await database('snapshot_archives').where({ archiveId: writer.archiveId }).update({ expiresAt: 0 }) + await store.reap() + assert.equal(Number((await database('snapshot_archive_capacity').first()).reservedBytes), 0) + assert.equal(Number((await database('snapshot_archive_pages').count({ count: '*' }))[0].count), 0) + console.log( + JSON.stringify({ + phase, + actualSignal: outcome.signal, + committedPages: committed, + ready, + exactReplay: phase === 'after-append-commit', + expiredCleanupComplete: true + }) + ) + } finally { + if (processHandle && processHandle.exitCode === null && processHandle.signalCode === null) { + processHandle.kill('SIGKILL') + await exited + } + if (database) await database.destroy() + fs.rmSync(directory, { recursive: true, force: true }) + } + }) +} +;(process.argv[2] === 'child' ? child(process.argv[3], process.argv[4]) : parent()).catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs new file mode 100644 index 000000000..296fffa71 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -0,0 +1,125 @@ +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') +const { knex } = require('knex') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const container = process.env.TS_STACK_SNAPSHOT_CONTAINER +const expectedId = process.env.TS_STACK_SNAPSHOT_CONTAINER_ID +if (!container || !expectedId) throw new Error('Use the bounded local fixture launcher') +const actual = JSON.parse(execFileSync('docker', ['inspect', container], { encoding: 'utf8' }))[0] +assert.equal(actual.Id, expectedId) +assert.equal(actual.Config.Labels['network-ops.fixture'], 'ts-stack-544-durable') +assert.equal(actual.Config.Image, 'mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d') +const port = Number( + execFileSync('docker', ['port', expectedId, '3306/tcp'], { encoding: 'utf8' }).trim().split(':').at(-1) +) +const connection = { + host: '127.0.0.1', + port, + user: 'root', + password: 'synthetic-snapshot-fixture', + database: 'ts569_snapshot', + timezone: 'Z' +} + +const { + KnexSnapshotArchiveStore, + snapshotArchiveTables, + snapshotArchiveLimits +} = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveStore.js') +const { addSnapshotArchiveTables } = require('../../out/src/storage/schema/snapshotArchiveMigration.js') +const open = () => knex({ client: 'mysql2', connection, pool: { min: 1, max: 1 } }) +const database = open(), + replica = open(), + third = open() +const identity = '02' + '11'.repeat(32), + other = '03' + '22'.repeat(32) +const date = new Date('2026-01-01T00:00:00.000Z') +const binding = { + version: 1, + snapshotId: 'a'.repeat(64), + sourceSchema: 'synthetic-v1', + sourceStorage: { + created_at: date, + updated_at: date, + storageIdentityKey: 'source', + storageName: 'source', + chain: 'test', + dbtype: 'MySQL', + maxOutputScript: 1024 + }, + user: { created_at: date, updated_at: date, userId: 7, identityKey: identity, activeStorage: 'source' } +} +async function main() { + try { + const version = (await database.raw('SELECT VERSION() AS version'))[0][0].version + await addSnapshotArchiveTables(database) + const store = new KnexSnapshotArchiveStore(database), + peer = new KnexSnapshotArchiveStore(replica), + final = new KnexSnapshotArchiveStore(third) + const writer = await store.begin(binding) + await assert.rejects(peer.inspect(identity, writer.archiveId), /unavailable/) + await assert.rejects(peer.begin(binding), /occupied/) + const payload = new Uint8Array(snapshotArchiveLimits.pageBytes).fill(42) + await runInSeries(snapshotArchiveTables.entries(), ([sequence, table]) => + store.append(writer, { sequence, table, rows: 1, done: true, bytes: payload }) + ) + const manifest = await store.seal(writer) + assert.deepEqual(manifest.binding, binding) + assert.equal(manifest.pages, 13) + assert.equal(manifest.rows, 13) + assert.deepEqual(await peer.inspect(identity, writer.archiveId), manifest) + assert.deepEqual((await peer.read(identity, writer.archiveId, 0)).bytes, payload) + await assert.rejects(peer.read(other, writer.archiveId, 0), /unavailable/) + await addSnapshotArchiveTables(database) + assert.equal(Number((await database('snapshot_archive_capacity').first()).archives), 1) + await Promise.all([peer.close(identity, writer.archiveId), final.close(identity, writer.archiveId)]) + assert.equal(Number((await database('snapshot_archive_pages').count({ count: '*' }))[0].count), 0) + assert.equal(Number((await database('snapshot_archive_capacity').first()).reservedBytes), 0) + // SQL failure after page insertion must roll back both the page and cursor. + const failing = await store.begin(binding) + await database.raw( + "CREATE TRIGGER synthetic_archive_failure BEFORE UPDATE ON snapshot_archives FOR EACH ROW BEGIN IF NEW.nextSequence > OLD.nextSequence THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic append failure'; END IF; END" + ) + await assert.rejects( + store.append(failing, { sequence: 0, table: 'provenTxs', rows: 0, done: true, bytes: new Uint8Array([91, 93]) }), + /synthetic append failure/ + ) + await database.raw('DROP TRIGGER synthetic_archive_failure') + assert.equal(Number((await database('snapshot_archive_pages').count({ count: '*' }))[0].count), 0) + assert.equal((await database('snapshot_archives').where({ archiveId: failing.archiveId }).first()).nextSequence, 0) + await database('snapshot_archives').where({ archiveId: failing.archiveId }).update({ expiresAt: 0 }) + await peer.reap() + assert.equal(Number((await database('snapshot_archive_capacity').first()).archives), 0) + // Independent servers racing for one profile can admit exactly one capture. + const contested = await Promise.allSettled([store.begin(binding), peer.begin(binding), final.begin(binding)]) + assert.equal(contested.filter(x => x.status === 'fulfilled').length, 1) + const admitted = contested.find(x => x.status === 'fulfilled').value + await peer.close(identity, admitted.archiveId) + await database.schema.dropTable('snapshot_archive_pages') + await addSnapshotArchiveTables(database) + assert.equal(await database.schema.hasTable('snapshot_archive_pages'), true) + assert.equal(Number((await database('snapshot_archive_capacity').first()).archives), 0) + console.log( + JSON.stringify({ + version, + source: 'built checkout; internal staging only', + crossReplicaImmutableReads: true, + exactMaximumPageBytes: payload.length, + profileOwnership: true, + concurrentCloseExactlyOnce: true, + appendRollback: true, + expiredPartialUnreadable: true, + profileReservationRace: true, + idempotentPartialDdl: true + }) + ) + } finally { + await third.destroy() + await replica.destroy() + await database.destroy() + } +} +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 298106fbe..addfeec48 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 35) + assert.equal(result.summary.propertySuites, 36) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 37) - assert.equal(result.summary.mutationTargets, 35) + assert.equal(result.summary.mutationTargets, 36) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 3f1ee939f..eeee21a1e 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -103,6 +103,15 @@ Tests terminate the destination process before a row, before checkpoint update, before transaction commit and after commit/before acknowledgement, then recover and finish without duplicate rows or partial checkpoints. +The subsequent internal SQL staging component adds shared immutable-page storage +for remote snapshots. It binds profile/source metadata, reserves bounded logical +capacity, commits pages and retry receipts atomically, and retains capacity until +resumable cleanup finishes. Independent-connection SQLite/MySQL and actual SQLite +process-termination fixtures cover its persistence boundaries. The source capture +controller, complete semantic closure validation, authenticated client/server +integration, larger-wallet resource policy and measured physical storage costs +remain open. The migration does not expose or advertise a remote snapshot API. + These checkpoints advance parts of S1/S2/P1/S4. They do not complete primary reconciliation, indexed identity/update predicates and commit-order high-water positions, authenticated remote views, durable source views, streaming or staged From 32c6b417a5a657845312917373265f584eddf0f5 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 08:55:08 -0700 Subject: [PATCH 048/127] feat(wallet): capture coherent snapshots in durable SQL staging Bind metadata and schema to one retained view, validate profile relations, and stage bounded packed pages with SQLite/MySQL and generated-case coverage. Preserve the complete snapshot-sync mutation campaign in three independently gated groups after the hosted monolithic job exceeded its deadline. Adapt the coordinated root-anchored Jest discovery fix. Authenticated remote transport and the full portability program remain incomplete. --- .github/workflows/ci.yml | 6 +- .github/workflows/mutation-tests.yml | 6 +- docs/guides/wallet-sync-reliability.md | 29 +- docs/reference/package-api-migrations.md | 76 ++-- docs/reference/test-quality-governance.md | 21 +- governance/mutation-testing/policy.json | 22 +- governance/mutation-testing/targets.mjs | 270 ++++++----- governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 27 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 6 +- .../wallet/wallet-toolbox/jest.config.cjs | 7 +- packages/wallet/wallet-toolbox/package.json | 2 +- .../ConcurrentSnapshotSyncSource.test.ts | 19 + .../snapshot/KnexWalletReadSnapshot.ts | 40 +- .../snapshot/SnapshotSync.integration.test.ts | 50 +- .../SnapshotSyncDestination.property.test.ts | 73 +++ .../SnapshotSyncRows.property.test.ts | 93 ++++ .../KnexSnapshotArchiveCapture.test.ts | 430 ++++++++++++++++++ .../archive/KnexSnapshotArchiveClosure.ts | 143 ++++++ .../archive/KnexSnapshotArchiveSource.ts | 70 +++ .../KnexSnapshotArchiveStore.property.test.ts | 97 ++++ .../archive/captureKnexSnapshotArchive.ts | 127 ++++++ .../test/storage/snapshotArchiveMysql.cjs | 126 ++++- .../test/utils/snapshotArchiveFixtures.ts | 134 ++++++ scripts/ci-orchestration.test.mjs | 3 +- scripts/test-governance.test.mjs | 4 +- specs/wallet/sync-portability-program.md | 12 +- 27 files changed, 1696 insertions(+), 201 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncDestination.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotArchiveFixtures.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4e8a51eb..0890b4da6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,9 +460,9 @@ jobs: if: always() && !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.mutation-targets != '[]' needs: prepare runs-on: ubuntu-latest - # Full wallet snapshot campaigns exceeded the hosted 45-minute deadline. - # Preserve their tests, mutants and per-test deadlines with a bounded allowance. - timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync"]'), matrix.target) && 90 || 45 }} + # Preserve complete wallet snapshot campaigns within a bounded allowance. + # Snapshot sync is partitioned after the unsplit campaign exceeded 90 minutes. + timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index e8946af39..1b040d343 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -78,9 +78,9 @@ jobs: name: Mutation / ${{ matrix.target }} needs: prepare runs-on: ubuntu-latest - # Full wallet snapshot campaigns exceeded the hosted 45-minute deadline. - # Preserve their tests, mutants and per-test deadlines with a bounded allowance. - timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync"]'), matrix.target) && 90 || 20 }} + # Preserve complete wallet snapshot campaigns within a bounded allowance. + # Snapshot sync is partitioned after the unsplit campaign exceeded 90 minutes. + timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows"]'), matrix.target) && 90 || 20 }} permissions: contents: read strategy: diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 1852b9035..54cc782da 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -353,6 +353,23 @@ capture becomes immutable and readable from another server connection. The capture controller must validate complete source closure before sealing it; this store alone does not validate wallet records or cryptographic proofs. +The internal `captureKnexSnapshotArchive` controller now takes a dedicated SQL +reader and a separate staging connection. It reads original settings, the +profile and the migration version in one retained view; verifies every selected +foreign-key relationship against that profile; and captures all thirteen raw +standard tables through the shared keyset reader. Relationship checks return +only an invalid-row marker, without loading payloads or building full ID maps. +Pages use the existing binary transport frame. They are not canonical BRC-38 +output, and this step does not authenticate transaction/proof contents. + +The controller reads at most 128 rows and a 128 KiB conservative SQL payload +charge per page, then checks the encoded one-MiB limit before staging. It reports +acknowledged page/row/byte counts, releases the source view before sealing, and +discards staging after cancellation or a capture failure. If cleanup itself +fails, the reservation remains available for expiry/reaping recovery. Callers +must supply independent reader/staging pools and keep schema migrations outside +active capture windows. No RPC route or advertised capability is added. + The initial policy allows at most eight handles and 128 MiB of logical reserved storage globally, one handle and 32 MiB per profile, 1 MiB per page, 1,000 rows per page and 4,096 pages. Metadata is at most 64 KiB. A reservation includes encoded @@ -370,7 +387,8 @@ capacity once. The current component requires its owning controller to invoke cleanup/reaping. No unattended worker or public capability is installed by the migration. -SQLite tests cover 300 generated capture schedules, independent hash-chain +SQLite tests cover 300 generated staging schedules and 300 actual SQL capture/ +cancellation schedules, independent hash-chain receipts, cross-profile reads and cleanup, exact limits, and failures between page insertion and checkpoint update. A synthetic process fixture terminates the writer at five durable boundaries: before data, after page insertion, after the @@ -378,9 +396,12 @@ checkpoint write, after transaction commit and after sealing but before the acknowledgement. Uncommitted pages roll back; committed pages retain exact retry receipts; only sealed captures are readable. Isolated MySQL 8.4.11 also exercises 1 MiB pages, independent connections racing for one profile, concurrent cleanup, -rollback and partial DDL recovery. These results apply to those fixtures. -Deployed PXC, authenticated HTTP, complete source capture and the full #544 -program still require implementation or qualification. +rollback and partial DDL recovery. Its controller fixture captures thirteen +tables over fourteen pages, retaining 140 original labels and primary metadata +while an independent writer updates both. It also verifies schema provenance, +packed binary and cross-profile relationship refusal. These results apply to +those fixtures. Deployed PXC, authenticated HTTP, canonical streaming, complete +portable semantic validation and the full #544 program remain open. Run the synthetic process-termination fixture from the repository root on macOS or Linux (Node 24 and the package build are required): diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 407ef29e7..753a7d46c 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; controller/semantic validation, authenticated integration and operator resource policy are still required. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; controller/semantic validation, authenticated integration and operator resource policy are still required. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 93280c7a4..921b4fd1f 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -198,15 +198,20 @@ lanes finish after a sibling failure, and every CI job has a reviewed timeout instead of GitHub's six-hour default. The zero-install orchestration tests enforce these resource and complete-campaign controls. -The `wallet-retained-snapshot` and `wallet-snapshot-sync` mutation jobs have a -90-minute limit in PR CI and the standalone mutation workflow. Both complete -campaigns reached the 45-minute hosted job deadline without producing a report -([retained-view job](https://github.com/bsv-blockchain/ts-stack/actions/runs/36713747014/job/109883824573), -[sync job](https://github.com/bsv-blockchain/ts-stack/actions/runs/36713747014/job/109883824697)). +The retained-snapshot campaign and the three snapshot-sync groups have a +90-minute limit in PR CI and the standalone mutation workflow. The retained +campaign completed within that allowance, but the complete snapshot-sync campaign +still exceeded it ([hosted timeout](https://github.com/bsv-blockchain/ts-stack/actions/runs/36721037438/job/109907026722)). +Snapshot sync is therefore divided into orchestration (`wallet-snapshot-sync`), +destination (`wallet-snapshot-sync-destination`) and row mapping +(`wallet-snapshot-sync-rows`). Their disjoint ranges preserve the entire original +source scope, and every group runs the complete original selected test suite. +Each group independently requires at least 90% detection and zero uncovered or +invalid mutants; an aggregate score cannot hide a weak group. + Other targets retain their respective 45-minute PR and 20-minute standalone -limits. This allowance changes only the job deadline: source scopes, test -selection, four mutation workers, six parallel jobs, individual mutant/test -deadlines and all quality ratchets are unchanged. A deadline cancellation is +limits. Four mutation workers, six parallel jobs, individual mutant/test +deadlines and all quality ratchets remain unchanged. A deadline cancellation is not a completed report or a passing score. List and run targets locally: diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 29ac464b4..39f48f9da 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -77,12 +77,32 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-snapshot-sync-destination", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncDestination.property.test.ts", + "risk": "critical", + "boundary": "Wallet durable snapshot destination binding, atomic checkpoint admission and primary-generation fencing", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-snapshot-sync-rows", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.property.test.ts", + "risk": "critical", + "boundary": "Wallet bounded snapshot row detachment and profile/source-scoped normalized parent and child ID mapping", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "wallet-snapshot-archive", "manifest": "packages/wallet/wallet-toolbox/package.json", "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts", "risk": "critical", - "boundary": "Wallet shared snapshot archive staging, immutable page receipts, profile isolation, quotas and physical cleanup accounting", + "boundary": "Wallet coherent SQL snapshot capture and staging, profile closure, immutable receipts, quotas and cleanup accounting", "minimumScore": 90, "maximumNoCoverage": 0, "maximumInvalid": 0 diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 3629471ac..d04b1c4b5 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -51,6 +51,160 @@ function vitestTarget(configFile) { } } +// Preserve the complete original snapshot-sync source ranges and selected tests. +// Each disjoint group runs the same behavioral suite and its own strict gate. +function snapshotSyncMutationTargets(repositoryRoot) { + const complete = { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts', + mutate: [ + 'src/utility/runInSeries.ts', + 'src/storage/snapshot/SnapshotSync.ts', + 'src/storage/snapshot/SnapshotSyncRows.ts', + 'src/storage/snapshot/KnexSnapshotSyncDestination.ts', + 'src/storage/snapshot/runSnapshotSyncSession.ts', + 'src/storage/schema/snapshotSyncMigration.ts', + 'src/storage/schema/entities/mergeSyncChunkEntities.ts', + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/sync/syncSession.ts', + 'if (chunk.user != null && session.activeStorage', + "notify('preparing', { readMs })" + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/methods/validateSyncProof.ts', + 'if (!(candidate.rawTx instanceof Uint8Array', + ' try {' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'this.snapshotSyncEnabled = options.snapshotSync', + 'this.preparedBeefPolicy =' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/schema/entities/EntityProvenTxReq.ts', + 'override async mergeExisting(', + 'export interface ProvenTxReqHistorySummaryApi' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override getSnapshotSync():', + 'protected override supportsNoSendExpiryPersistence()' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'private async runSnapshotCopy(', + 'async syncFromReader(' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async syncFromReader(', + ' let inserts = 0' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async syncFromReaderResumable(', + ' const generation =' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async syncToWriterResumable(', + 'async syncToWriter(' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async syncToWriter(', + ' let inserts = 0' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async updateBackups(', + 'async setActive(' + ) + ], + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/snapshot/SnapshotSync*.test.ts', + '/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts', + '/src/storage/schema/snapshotSyncMigration.test.ts', + '/src/storage/methods/validateSyncProof.test.ts', + '/src/storage/sync/syncFailure.test.ts', + '/src/storage/sync/syncSession.test.ts', + '/src/utility/__tests__/runInSeries.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + } + const destination = new Set([ + 'src/storage/snapshot/KnexSnapshotSyncDestination.ts', + 'src/storage/schema/snapshotSyncMigration.ts' + ]) + const rows = new Set([ + 'src/storage/snapshot/SnapshotSyncRows.ts', + 'src/storage/schema/entities/mergeSyncChunkEntities.ts', + 'src/storage/schema/entities/EntityProvenTxReq.ts', + 'src/storage/methods/validateSyncProof.ts' + ]) + const groups = { + 'wallet-snapshot-sync': [], + 'wallet-snapshot-sync-destination': [], + 'wallet-snapshot-sync-rows': [] + } + for (const range of complete.mutate) { + const file = range.replace(/:\d+(?:-\d+)?$/, '') + const name = destination.has(file) + ? 'wallet-snapshot-sync-destination' + : rows.has(file) + ? 'wallet-snapshot-sync-rows' + : 'wallet-snapshot-sync' + groups[name].push(range) + } + return Object.fromEntries( + Object.entries(groups).map(([name, mutate]) => [ + name, + { + ...complete, + mutate, + propertyTest: + name === 'wallet-snapshot-sync' + ? complete.propertyTest + : `packages/wallet/wallet-toolbox/src/storage/snapshot/${name === 'wallet-snapshot-sync-destination' ? 'SnapshotSyncDestination' : 'SnapshotSyncRows'}.property.test.ts` + } + ]) + ) +} + export function buildMutationTargets(repositoryRoot) { return { 'sdk-codecs': { @@ -269,6 +423,9 @@ export function buildMutationTargets(repositoryRoot) { 'packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts', mutate: [ 'src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts', + 'src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts', + 'src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts', + 'src/storage/snapshot/archive/captureKnexSnapshotArchive.ts', 'src/storage/schema/snapshotArchiveMigration.ts' ], ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/archive/*.test.ts'], { @@ -280,118 +437,7 @@ export function buildMutationTargets(repositoryRoot) { } }) }, - 'wallet-snapshot-sync': { - packageDirectory: 'packages/wallet/wallet-toolbox', - manifest: 'packages/wallet/wallet-toolbox/package.json', - propertyTest: - 'packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts', - mutate: [ - 'src/utility/runInSeries.ts', - 'src/storage/snapshot/SnapshotSync.ts', - 'src/storage/snapshot/SnapshotSyncRows.ts', - 'src/storage/snapshot/KnexSnapshotSyncDestination.ts', - 'src/storage/snapshot/runSnapshotSyncSession.ts', - 'src/storage/schema/snapshotSyncMigration.ts', - 'src/storage/schema/entities/mergeSyncChunkEntities.ts', - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/sync/syncSession.ts', - 'if (chunk.user != null && session.activeStorage', - "notify('preparing', { readMs })" - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/methods/validateSyncProof.ts', - 'if (!(candidate.rawTx instanceof Uint8Array', - ' try {' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/StorageKnex.ts', - 'this.snapshotSyncEnabled = options.snapshotSync', - 'this.preparedBeefPolicy =' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/schema/entities/EntityProvenTxReq.ts', - 'override async mergeExisting(', - 'export interface ProvenTxReqHistorySummaryApi' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/StorageKnex.ts', - 'override getSnapshotSync():', - 'protected override supportsNoSendExpiryPersistence()' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/WalletStorageManager.ts', - 'private async runSnapshotCopy(', - 'async syncFromReader(' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/WalletStorageManager.ts', - 'async syncFromReader(', - ' let inserts = 0' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/WalletStorageManager.ts', - 'async syncFromReaderResumable(', - ' const generation =' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/WalletStorageManager.ts', - 'async syncToWriterResumable(', - 'async syncToWriter(' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/WalletStorageManager.ts', - 'async syncToWriter(', - ' let inserts = 0' - ), - sourceLineRange( - repositoryRoot, - 'packages/wallet/wallet-toolbox', - 'src/storage/WalletStorageManager.ts', - 'async updateBackups(', - 'async setActive(' - ) - ], - ...jestTarget( - 'jest.config.cjs', - [ - '/src/storage/snapshot/SnapshotSync*.test.ts', - '/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts', - '/src/storage/schema/snapshotSyncMigration.test.ts', - '/src/storage/methods/validateSyncProof.test.ts', - '/src/storage/sync/syncFailure.test.ts', - '/src/storage/sync/syncSession.test.ts', - '/src/utility/__tests__/runInSeries.test.ts' - ], - { - config: { - moduleNameMapper: { - '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), - '^(\\.{1,2}/.*)\\.js$': '$1' - } - } - } - ) - }, + ...snapshotSyncMutationTargets(repositoryRoot), 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', manifest: 'packages/overlays/topics/package.json', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 5250e20fc..3f5721ff1 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,8 +217,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; controller/semantic validation, authenticated integration and operator resource policy are still required. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 48be93843..8352f243f 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -122,6 +122,28 @@ "A restart explicitly begins the replacement view at its first table while preserving existing destination data and legacy checkpoints." ] }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncDestination.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet durable snapshot destination binding, atomic checkpoint admission and primary-generation fencing", + "target": "Randomized original profile metadata, same-view binding drift, independent profiles and replacement views", + "invariants": [ + "Same-view metadata changes reject without modifying the durable checkpoint.", + "A replacement view starts at table zero with a fresh session while preserving another profile." + ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet bounded snapshot row detachment and profile/source-scoped normalized parent and child ID mapping", + "target": "Randomized overlapping incoming IDs across profiles and sources with independent expected mapping and persisted-row oracles", + "invariants": [ + "Incoming parent IDs resolve only within the selected profile and source storage.", + "Only newly learned child IDs are persisted, existing parent and foreign mappings remain unchanged, and missing parents reject." + ] + }, { "path": "packages/overlays/topics/src/mandala/__tests/types.property.test.ts", "manifest": "packages/overlays/topics/package.json", @@ -491,13 +513,14 @@ "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts", "manifest": "packages/wallet/wallet-toolbox/package.json", "risk": "critical", - "boundary": "Wallet shared snapshot archive staging, immutable page receipts, profile isolation, quotas and physical cleanup accounting", + "boundary": "Wallet coherent SQL snapshot capture and staging, profile closure, immutable receipts, quotas and cleanup accounting", "target": "Persistent bounded archive staging across capture retries, incomplete captures, independent readers and expired/cancelled cleanup", "invariants": [ "A partial or expired capture is never readable as a completed archive.", "Exact page retries preserve immutable bytes and the independent hash-chain receipt without double charging.", "The authenticated profile cannot read or release another profile's archive.", - "Capacity remains reserved through interrupted cleanup and is released exactly once after every page is deleted." + "Capacity remains reserved through interrupted cleanup and is released exactly once after every page is deleted.", + "A complete SQL capture contains the selected profile\u2019s original rows; cancellation removes all staged pages and reservations without activating wallet data." ] } ], diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index a71c1b995..ead455e29 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -10,8 +10,10 @@ attention to changes that materially alter behavior or extend functionality. immutable completed pages, exact replay receipts, explicit logical byte/page reservations and resumable bounded cleanup. A second auxiliary migration leaves standard tables, legacy sync checkpoints and wire formats unchanged. - This is an internal prerequisite: source capture/closure validation, authenticated - remote endpoints and larger-wallet resource policy remain under implementation. + The local capture controller binds source metadata/schema to one read view, + verifies profile relationships and stores all thirteen raw tables through + bounded binary frames, with cancellation and cleanup. Canonical portable + validation, authenticated endpoints and larger-wallet policy remain open. - Integrate coherent SQL pages into ordinary local push, pull and backup, with a dedicated source reader and short, fair destination commits. Add resumable diff --git a/packages/wallet/wallet-toolbox/jest.config.cjs b/packages/wallet/wallet-toolbox/jest.config.cjs index 04aa4c774..1cbc2440c 100644 --- a/packages/wallet/wallet-toolbox/jest.config.cjs +++ b/packages/wallet/wallet-toolbox/jest.config.cjs @@ -13,7 +13,12 @@ const getJestConfig = async () => { // Speed up by restricting to module (source files) extensions used. moduleFileExtensions: ['ts', 'js'], // excluded source files... - modulePathIgnorePatterns: ['out/src', 'out/test', '/dist/cjs/'], + modulePathIgnorePatterns: ['out/src', 'out/test', '/dist/cjs/', '/\\.stryker-tmp/'], + // Ignore generated children of this test root. Stryker runs with a sandbox + // as its root, so an unanchored sandbox pattern would hide its own tests. + // Module discovery retains the same boundary when package commands replace + // testPathIgnorePatterns with their governed manual/live test policy. + testPathIgnorePatterns: ['/node_modules/', '/\\.stryker-tmp/'], // Default is 'node' testEnvironment: 'node', // default [ '**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)' ] diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 4074496c7..e2d8ac963 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts index da85c3fb5..9bf3f8b9c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts @@ -244,3 +244,22 @@ test.each([null, 7])('unsupported connection value %p refuses before constructin source.knex.client.config.connection = original } }) + +test('invalid static MySQL database metadata never creates a private reader', async () => { + const source = mysql() + const opening = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot').mockResolvedValue(view()) + for (const database of [null, 42, undefined]) { + source.knex.client.config.connection = { host: '127.0.0.1', database } + expect(await source.getSnapshotSync()!.openSource('identity')).toBeUndefined() + } + expect(opening).not.toHaveBeenCalled() +}) + +test('a pre-cancelled source does no database or reader work', async () => { + const source = mysql() + const opening = jest.spyOn(StorageKnex.prototype, 'openWalletReadSnapshot').mockResolvedValue(view()) + const signal = new AbortController() + signal.abort() + await expect(source.getSnapshotSync()!.openSource('identity', { signal: signal.signal })).rejects.toThrow('cancelled') + expect(opening).not.toHaveBeenCalled() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index 4d91d3ad7..65e153ee8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -4,6 +4,7 @@ import type { Knex } from 'knex' import type { StorageKnex } from '../StorageKnex' import type { TrxToken } from '../../sdk/WalletStorage.interfaces' import { WERR_INVALID_PARAMETER, WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { RetainedReadSnapshot } from './RetainedReadSnapshot' import type { PackedSnapshotRow, WalletReadSnapshot, @@ -94,7 +95,8 @@ function owned(k: Knex, table: string, id: string, source: string, userId: numbe .whereRaw('?? = ??', [`${table}.${id}`, source]) } -function scopedQuery(k: Knex, table: WalletSnapshotTable, userId: number): Knex.QueryBuilder { +/** Shared profile selection for local paging and archive closure checks. */ +export function walletSnapshotSourceQuery(k: Knex, table: WalletSnapshotTable, userId: number): Knex.QueryBuilder { const { name } = definitions[table] const query = k(name) if (table === 'provenTxReqs') { @@ -246,7 +248,7 @@ async function readPage( columns.set(table, fields) } const base = (): Knex.QueryBuilder => { - const q = scopedQuery(k, table, userId) + const q = walletSnapshotSourceQuery(k, table, userId) if (after !== undefined) seek(q, schema.keys, after) for (const key of schema.keys) void q.orderBy(key) return q @@ -273,6 +275,27 @@ async function readPage( return { rows, payloadBytes, cursor, done: count === candidates.length && candidates.length < limits.maxRows } } +/** Bind every page to one provider-owned view and immutable profile identifiers. */ +export function createKnexWalletSnapshotPageReader( + storage: StorageKnex, + userId: number, + snapshotId: string, + view: RetainedReadSnapshot +): WalletReadSnapshot['readPage'] { + const context: SnapshotContext = { storage, userId, snapshotId, columns: new Map() } + return async ( + table: T, + cursor?: WalletSnapshotCursor, + limits: WalletSnapshotPageLimits = {} + ): Promise> => { + const schema = definition(table) + const after = position(cursor, snapshotId, table, schema.keys) + const maxRows = bound(limits.maxRows, 128, 1000, 'maxRows') + const maxBytes = bound(limits.maxBytes, 262144, 16777216, 'maxBytes') + return await view.read(trx => readPage(context, trx, table, after, { maxRows, maxBytes })) + } +} + /** SQL implementation, deliberately separate from legacy OFFSET sync and public RPC. */ export async function openKnexWalletReadSnapshot( storage: StorageKnex, @@ -292,7 +315,6 @@ export async function openKnexWalletReadSnapshot( }) const userId = header.user.userId const snapshotId = Utils.toHex(Random(32)) - const context: SnapshotContext = { storage, userId, snapshotId, columns: new Map() } return { version: 1, snapshotId, @@ -303,17 +325,7 @@ export async function openKnexWalletReadSnapshot( }, closed: view.closed, close: view.close, - async readPage( - table: T, - cursor?: WalletSnapshotCursor, - limits: WalletSnapshotPageLimits = {} - ): Promise> { - const schema = definition(table) - const after = position(cursor, snapshotId, table, schema.keys) - const maxRows = bound(limits.maxRows, 128, 1000, 'maxRows') - const maxBytes = bound(limits.maxBytes, 262144, 16777216, 'maxBytes') - return await view.read(trx => readPage(context, trx, table, after, { maxRows, maxBytes })) - } + readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view) } } catch (error) { // Keep the opening error authoritative while still awaiting physical cleanup. diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts index 3bba7bfec..2101f631b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts @@ -873,6 +873,15 @@ test('an uncommitted auxiliary migration keeps ordinary backup on the compatible test('large valid rows select serialized fallback after bounded commits without losing counts or data', async () => { const { source, destination, manager, user } = await fixture(2) + const destinationUser = (await destination.findUserByIdentityKey(identity))! + await destination.insertTxLabel({ + txLabelId: 0, + userId: destinationUser.userId, + created_at: new Date('2025-01-01'), + updated_at: new Date('2025-01-01'), + label: 'label-0', + isDeleted: false + }) const bytes = new Uint8Array(200000).fill(173) await source.insertTransaction({ transactionId: 0, @@ -888,12 +897,23 @@ test('large valid rows select serialized fallback after bounded commits without }) const legacy = jest.spyOn(destination, 'processSyncChunk') const modes: string[] = [] + const progress: Array<{ mode: string; state: string; pages: number; inserts: number; updates: number }> = [] const result = await manager.syncToWriterResumable(await manager.getAuth(), destination, { - onProgress: p => modes.push(p.mode) + onProgress: p => { + modes.push(p.mode) + progress.push({ ...p }) + } }) expect(result.status).toBe('completed') expect(result.mode).toBe('exclusive') - expect(result.inserts).toBe(3) + expect(result.inserts).toBe(2) + expect(result.updates).toBe(1) + const paged = progress.filter(p => p.mode === 'paged').at(-1)! + const exclusive = progress.filter(p => p.mode === 'exclusive') + expect(paged).toMatchObject({ inserts: 1, updates: 1 }) + expect(exclusive[0]).toMatchObject({ pages: paged.pages, inserts: 1, updates: 1 }) + expect(exclusive.at(-1)).toMatchObject({ pages: result.pages, inserts: 2, updates: 1 }) + expect(result.pages).toBe(paged.pages + exclusive.filter(p => p.state === 'committed').length) expect(modes).toContain('paged') expect(modes).toContain('exclusive') expect(legacy).toHaveBeenCalled() @@ -1443,3 +1463,29 @@ test('snapshot capability configuration rejects a non-boolean before database ad await database.destroy() } }) + +test('an expiry during cleanup preserves an earlier malformed-page failure', async () => { + const { source, destination, manager } = await fixture(1) + const capability = source.getSnapshotSync()! + const failure = new Error('synthetic malformed page') + const legacy = jest.spyOn(destination, 'processSyncChunk') + jest.spyOn(source, 'getSnapshotSync').mockReturnValue({ + ...capability, + openSource: async (...args) => { + const view = (await capability.openSource(...args))! + return { + ...view, + readPage: async () => { + throw failure + }, + close: async () => { + await view.close() + throw new SnapshotResourceLimitError('synthetic cleanup expiry') + } + } + } + }) + await expect(manager.syncToWriterResumable({ identityKey: identity }, destination)).rejects.toBe(failure) + expect(legacy).not.toHaveBeenCalled() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncDestination.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncDestination.property.test.ts new file mode 100644 index 000000000..486a10667 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncDestination.property.test.ts @@ -0,0 +1,73 @@ +import fc from 'fast-check' +import { knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import type { SnapshotSyncSource } from './SnapshotSync' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +test('random profile bindings reject same-view drift without changing durable checkpoints or another profile', async () => { + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + }) + const identities = ['02' + '11'.repeat(32), '03' + '22'.repeat(32)] + try { + await storage.migrate('destination', 'destination') + await storage.makeAvailable() + const writer = storage.getSnapshotSync()! + const users = [] + for (const identity of identities) users.push((await storage.findOrInsertUser(identity)).user) + await fc.assert( + fc.asyncProperty( + fc.boolean(), + fc.integer({ min: 1, max: 1000000 }), + fc.integer({ min: 1, max: 1000000 }), + fc.constantFrom('userId', 'created_at', 'updated_at', 'activeStorage', 'expiresAt'), + async (second, sourceId, epoch, field) => { + await storage.knex('snapshot_sync_sessions').delete() + const selected = Number(second) + const date = new Date(1600000000000 + epoch) + const source: SnapshotSyncSource = { + version: 1, + snapshotId: 'a'.repeat(64), + sourceStorage: { ...storage.getSettings(), storageIdentityKey: 'source' }, + user: { ...users[selected], userId: sourceId, created_at: date, updated_at: date, activeStorage: 'source' }, + expiresAt: Date.now() + 600000 + } + const other: SnapshotSyncSource = { + ...source, + user: { ...source.user, identityKey: identities[1 - selected] } + } + const otherCheckpoint = await writer.begin(other, 'source') + const checkpoint = await writer.begin(source, 'source') + expect(checkpoint.identityKey).toBe(identities[selected]) + expect(await writer.begin(source, 'source')).toEqual(checkpoint) + const changed = { ...source, user: { ...source.user } } + if (field === 'userId') changed.user.userId++ + else if (field === 'created_at' || field === 'updated_at') changed.user[field] = new Date(date.getTime() + 1) + else if (field === 'activeStorage') changed.user.activeStorage = 'changed-primary' + else changed.expiresAt++ + await expect(writer.begin(changed, changed.user.activeStorage)).rejects.toThrow('binding changed') + expect(await writer.checkpoint(identities[selected], 'source')).toEqual(checkpoint) + expect(await writer.checkpoint(identities[1 - selected], 'source')).toEqual(otherCheckpoint) + expect(await storage.knex('snapshot_sync_ids')).toHaveLength(0) + const replacement = await writer.begin({ ...source, snapshotId: 'b'.repeat(64) }, 'source') + expect(replacement).toMatchObject({ snapshotId: 'b'.repeat(64), sequence: 0, tableIndex: 0, done: false }) + expect(replacement.sessionId).not.toBe(checkpoint.sessionId) + expect(await writer.checkpoint(identities[1 - selected], 'source')).toEqual(otherCheckpoint) + } + ) + ) + } finally { + await storage.destroy() + } +}, 120000) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.property.test.ts new file mode 100644 index 000000000..03b799b75 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.property.test.ts @@ -0,0 +1,93 @@ +import fc from 'fast-check' +import { knex } from 'knex' +import { loadSnapshotIdMap } from './SnapshotSyncRows' +import type { SyncChunk } from '../../sdk/WalletStorage.interfaces' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +test('random overlapping parent IDs preserve exact profile/source maps and persist only learned child IDs', async () => { + const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + try { + await database.schema.createTable('snapshot_sync_ids', table => { + table.integer('userId') + table.string('sourceStorageIdentityKey') + table.string('entity') + table.integer('incomingId') + table.integer('localId') + table.primary(['userId', 'sourceStorageIdentityKey', 'entity', 'incomingId']) + }) + await fc.assert( + fc.asyncProperty( + fc.uniqueArray(fc.integer({ min: 1, max: 100000 }), { minLength: 1, maxLength: 12 }), + fc.integer({ min: 1, max: 10000 }), + fc.boolean(), + async (incoming, userId, second) => { + await database('snapshot_sync_ids').delete() + const scope = { userId, sourceStorageIdentityKey: second ? 'second' : 'first' } + const original = incoming.flatMap((id, index) => [ + { ...scope, entity: 'transaction', incomingId: id, localId: index + 101 }, + { ...scope, userId: userId + 1, entity: 'transaction', incomingId: id, localId: index + 201 }, + { + ...scope, + sourceStorageIdentityKey: second ? 'first' : 'second', + entity: 'transaction', + incomingId: id, + localId: index + 301 + } + ]) + await database('snapshot_sync_ids').insert(original) + const chunk = { + userIdentityKey: 'identity', + fromStorageIdentityKey: scope.sourceStorageIdentityKey, + toStorageIdentityKey: 'destination', + outputs: incoming.map(id => ({ userId: 77, outputId: id, transactionId: id, spentBy: id })) + } as SyncChunk + const loaded = await loadSnapshotIdMap(database, scope, 77, 'outputs', chunk) + expect(loaded.map.transaction.idMap).toEqual( + Object.fromEntries(incoming.map((id, index) => [id, index + 101])) + ) + expect(loaded.map.output.idMap).toEqual({}) + for (const [index, id] of incoming.entries()) loaded.map.output.idMap[id] = index + 401 + await loaded.persist() + expect( + await database('snapshot_sync_ids') + .where({ entity: 'transaction' }) + .orderBy(['userId', 'sourceStorageIdentityKey', 'incomingId']) + ).toEqual( + [...original].sort( + (a, b) => + a.userId - b.userId || + a.sourceStorageIdentityKey.localeCompare(b.sourceStorageIdentityKey) || + a.incomingId - b.incomingId + ) + ) + expect( + await database('snapshot_sync_ids') + .where({ ...scope, entity: 'output' }) + .orderBy('incomingId') + ).toEqual( + incoming + .map((id, index) => ({ ...scope, entity: 'output', incomingId: id, localId: index + 401 })) + .sort((a, b) => a.incomingId - b.incomingId) + ) + expect(await database('snapshot_sync_ids')).toHaveLength(incoming.length * 4) + const missing = { ...chunk, outputs: [{ userId: 77, outputId: 100001, transactionId: 100001 }] } as SyncChunk + await expect(loadSnapshotIdMap(database, scope, 77, 'outputs', missing)).rejects.toThrow( + 'parent mapping is missing' + ) + expect(await database('snapshot_sync_ids')).toHaveLength(incoming.length * 4) + } + ) + ) + } finally { + await database.destroy() + } +}, 120000) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts new file mode 100644 index 000000000..a2c718a55 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -0,0 +1,430 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { SNAPSHOT_ARCHIVE_MIGRATION } from '../../schema/KnexMigrations' +import { decodeSyncTransfer } from '../../remoting/SyncTransfer' +import * as Transfer from '../../remoting/SyncTransfer' +import * as ArchiveSource from './KnexSnapshotArchiveSource' +import { runInNewContext } from 'node:vm' +import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' +import { openKnexSnapshotArchiveSource } from './KnexSnapshotArchiveSource' +import { assertKnexSnapshotArchiveClosure } from './KnexSnapshotArchiveClosure' +import { captureKnexSnapshotArchive } from './captureKnexSnapshotArchive' +import { KnexSnapshotArchiveStore, snapshotArchiveTables } from './KnexSnapshotArchiveStore' + +const identity = '02' + '11'.repeat(32) +const foreign = '03' + '22'.repeat(32) +const stores: StorageKnex[] = [] +const directories: string[] = [] + +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'wallet-archive-capture-')) + directories.push(directory) + const open = () => { + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 1000 + }) + }) + stores.push(storage) + return storage + } + const writer = open() + await writer.knex.raw('PRAGMA journal_mode = WAL') + await writer.migrate('original source', 'original-source') + await writer.makeAvailable() + const { user } = await writer.findOrInsertUser(identity) + const { user: other } = await writer.findOrInsertUser(foreign) + await seedArchiveClosure(writer, user.userId, other.userId) + await writer.knex('outputs').where({ outputId: 1 }).update({ spentBy: 3 }) + const reader = open() + await reader.makeAvailable() + return { reader, writer, userId: user.userId, otherId: other.userId } +} + +afterEach(async () => { + jest.restoreAllMocks() + await Promise.all(stores.splice(0).map(storage => storage.destroy())) + await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +async function changeReference(writer: StorageKnex, table: string, key: string, field: string, value: number) { + if (table === 'commissions') await writer.knex('commissions').where({ transactionId: value }).delete() + const query = writer.knex(table).where({ [key]: 1 }) + if (table.endsWith('_map')) void query.where({ [field]: 1 }) + if (table === 'certificate_fields') { + await query.where({ certificateId: 1, fieldName: 'a' }).update({ [field]: value, fieldName: 'changed' }) + } else await query.update({ [field]: value }) +} + +test('captures all thirteen tables with original metadata, packed bytes and profile-bound relations', async () => { + const { reader, writer, userId } = await fixture() + const progress: Array<{ pages: number; rows: number; bytes: number }> = [] + const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { + onProgress: p => progress.push(p) + }) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_ARCHIVE_MIGRATION) + expect(manifest.binding.sourceStorage.storageName).toBe('original source') + expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') + expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) + expect(manifest.pages).toBe(13) + const store = new KnexSnapshotArchiveStore(writer.knex) + const captured: Record>> = {} + for (const [sequence, table] of snapshotArchiveTables.entries()) { + const page = await store.read(identity, manifest.archiveId, sequence) + const frame = decodeSyncTransfer(page.bytes) as { + version: number + table: string + rows: Array> + } + expect(frame).toMatchObject({ version: 1, table }) + expect(frame.rows).toHaveLength(page.rows) + expect(page.done).toBe(true) + captured[table] = frame.rows + for (const row of frame.rows) { + if ('userId' in row) expect(row.userId).toBe(userId) + expect(row.created_at).toBe('2026-01-01T00:00:00.000Z') + } + } + expect(captured.provenTxs.map(row => row.provenTxId)).toEqual([1, 3]) + expect(captured.provenTxs[0].rawTx).toEqual(new Uint8Array([1, 1, 255])) + expect(captured.provenTxReqs.map(row => row.provenTxReqId)).toEqual([1, 3]) + expect(captured.transactions.map(row => row.transactionId)).toEqual([1, 3]) + expect(captured.txLabelMaps.map(row => [row.txLabelId, row.transactionId])).toEqual([ + [1, 1], + [1, 3], + [3, 3] + ]) + expect(captured.syncStates.map(row => [row.syncStateId, row.syncMap])).toEqual([ + [1, '{}'], + [3, '{}'] + ]) + expect(captured.certificateFields).toHaveLength(8) + expect(progress.at(-1)).toMatchObject({ pages: 13, rows: manifest.rows }) + expect(progress.at(-1)!.bytes).toBeGreaterThan(0) + expect(progress[0].pages).toBe(1) + const next = await reader.openReadSnapshot() + await next.close() + await store.close(identity, manifest.archiveId) +}) + +test('preserves nullable proof, basket and spending references in an incomplete wallet history', async () => { + const { reader, writer } = await fixture() + await writer.knex('transactions').where({ transactionId: 1 }).update({ provenTxId: null }) + await writer.knex('proven_tx_reqs').where({ provenTxReqId: 1 }).update({ provenTxId: null }) + await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: null, spentBy: null }) + const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test') + const store = new KnexSnapshotArchiveStore(writer.knex) + for (const [sequence, field] of [ + [1, 'provenTxId'], + [3, 'provenTxId'], + [5, 'basketId'] + ] as const) { + const page = await store.read(identity, manifest.archiveId, sequence) + const frame = decodeSyncTransfer(page.bytes) as { rows: Array> } + expect(frame.rows[0][field]).toBeUndefined() + if (sequence === 5) expect(frame.rows[0].spentBy).toBeUndefined() + } + await store.close(identity, manifest.archiveId) +}) + +test('source schema, primary history and closure stay pinned while an independent writer changes them', async () => { + const { reader, writer } = await fixture() + const source = await openKnexSnapshotArchiveSource(reader, identity) + const originalPrimary = source.user.activeStorage + await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) + await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) + await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) + expect(source.sourceSchema).toBe(SNAPSHOT_ARCHIVE_MIGRATION) + expect(source.user.activeStorage).toBe(originalPrimary) + await expect(source.validateClosure()).resolves.toBeUndefined() + expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) + await source.close() + const changed = await openKnexSnapshotArchiveSource(reader, identity) + expect(changed.sourceSchema).toBe('future-schema') + expect(changed.user.activeStorage).toBe('replacement') + await expect(changed.validateClosure()).rejects.toThrow('relation') + await expect(changed.closed).rejects.toThrow('relation') +}) + +test.each([ + ['transactions', 'transactionId', 'provenTxId'], + ['proven_tx_reqs', 'provenTxReqId', 'provenTxId'], + ['commissions', 'commissionId', 'transactionId'], + ['outputs', 'outputId', 'transactionId'], + ['outputs', 'outputId', 'basketId'], + ['outputs', 'outputId', 'spentBy'], + ['tx_labels_map', 'txLabelId', 'transactionId'], + ['tx_labels_map', 'transactionId', 'txLabelId'], + ['output_tags_map', 'outputTagId', 'outputId'], + ['output_tags_map', 'outputId', 'outputTagId'], + ['certificate_fields', 'certificateId', 'userId'], + ['certificate_fields', 'userId', 'certificateId'] +])('refuses dangling %s.%s/%s without sealing or leaking a reservation', async (table, key, field) => { + const { reader, writer } = await fixture() + await writer.knex.raw('PRAGMA foreign_keys = OFF') + await changeReference(writer, table, key, field, 999) + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'test')).rejects.toThrow('relation') + expect(await writer.knex('snapshot_archives')).toHaveLength(0) + expect(await writer.knex('snapshot_archive_pages')).toHaveLength(0) + expect(await writer.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test.each([ + ['commissions', 'commissionId', 'transactionId'], + ['outputs', 'outputId', 'transactionId'], + ['outputs', 'outputId', 'basketId'], + ['outputs', 'outputId', 'spentBy'], + ['tx_labels_map', 'txLabelId', 'transactionId'], + ['tx_labels_map', 'transactionId', 'txLabelId'], + ['output_tags_map', 'outputTagId', 'outputId'], + ['output_tags_map', 'outputId', 'outputTagId'], + ['certificate_fields', 'certificateId', 'userId'], + ['certificate_fields', 'userId', 'certificateId'] +])('refuses existing foreign-profile targets in %s.%s/%s', async (table, key, field) => { + const { reader, writer } = await fixture() + await changeReference(writer, table, key, field, 2) + const source = await openKnexSnapshotArchiveSource(reader, identity) + await expect(source.validateClosure()).rejects.toThrow('relation') + await expect(source.closed).rejects.toThrow('relation') +}) + +test('cancellation and progress callback failures discard committed staging and release the reader', async () => { + const { reader, writer } = await fixture() + const signal = new AbortController() + await expect( + captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { + signal: signal.signal, + onProgress: () => signal.abort() + }) + ).rejects.toThrow('cancelled') + expect(await writer.knex('snapshot_archive_pages')).toHaveLength(0) + const failure = new Error('synthetic progress failure') + await expect( + captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { + onProgress: () => { + throw failure + } + }) + ).rejects.toBe(failure) + expect(await writer.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + const next = await reader.openReadSnapshot() + await next.close() +}) + +test('invalid binding, network, shared-pool and already-cancelled requests fail with no partial archive', async () => { + const { reader, writer } = await fixture() + await expect(captureKnexSnapshotArchive(reader, reader.knex, identity, 'test')).rejects.toThrow('separate') + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'mock')).rejects.toThrow('chain') + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'main')).rejects.toThrow('chain') + await expect(captureKnexSnapshotArchive(reader, writer.knex, 'invalid', 'test')).rejects.toThrow('identityKey') + await expect(captureKnexSnapshotArchive(reader, writer.knex, '02' + '33'.repeat(32), 'test')).rejects.toThrow( + 'existing wallet profile' + ) + const signal = new AbortController() + signal.abort() + await expect( + captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { signal: signal.signal }) + ).rejects.toThrow('cancelled') + expect(await writer.knex('snapshot_archives')).toHaveLength(0) + const queries: string[] = [] + writer.knex.on('query', query => queries.push(query.sql)) + for (const id of [0, -1, 1.5, NaN, Number.MAX_SAFE_INTEGER + 1]) + await expect(assertKnexSnapshotArchiveClosure(writer.knex, id)).rejects.toThrow('userId') + expect(queries).toHaveLength(0) +}) + +test('malformed identities refuse the source before acquiring a database read view', async () => { + const { reader } = await fixture() + const queries: string[] = [] + reader.knex.on('query', query => queries.push(query.sql)) + for (const key of [null, 42, '', 'invalid', 'prefix' + identity, identity + 'suffix', '04' + '11'.repeat(32)]) { + await expect(openKnexSnapshotArchiveSource(reader, key as string)).rejects.toThrow('compressed public identity key') + } + expect(queries).toHaveLength(0) +}) + +test('capture checks source identity independently and forwards cancellation to the retained view', async () => { + const { reader, writer } = await fixture() + const source = await openKnexSnapshotArchiveSource(reader, identity) + jest.spyOn(ArchiveSource, 'openKnexSnapshotArchiveSource').mockResolvedValue({ + ...source, + user: { ...source.user, identityKey: foreign } + }) + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'test')).rejects.toThrow('requested profile') + expect(source.isOpen).toBe(false) + expect(await writer.knex('snapshot_archives')).toHaveLength(0) + jest.restoreAllMocks() + const signal = new AbortController() + const open = reader.openReadSnapshot.bind(reader) + let captured: Awaited> | undefined + jest.spyOn(reader, 'openReadSnapshot').mockImplementation(async options => { + captured = await open(options) + return captured + }) + await expect( + captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { + signal: signal.signal, + lifetimeMs: 60000, + onProgress: () => { + signal.abort() + expect(captured?.isOpen).toBe(false) + } + }) + ).rejects.toThrow('cancelled') + expect(await writer.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('missing or invalid migration metadata refuses opening and releases read capacity', async () => { + const { reader, writer } = await fixture() + const metadata = await writer.knex('knex_migrations').orderBy('id', 'desc').first() + for (const name of ['', 'x'.repeat(257)]) { + await writer.knex('knex_migrations').where({ id: metadata.id }).update({ name }) + await expect(openKnexSnapshotArchiveSource(reader, identity)).rejects.toThrow('schema version') + } + await writer.knex('knex_migrations').delete() + await expect(openKnexSnapshotArchiveSource(reader, identity)).rejects.toThrow('schema version') + const next = await reader.openReadSnapshot() + await next.close() +}) + +test('supports a configured migration table/schema and preserves the longest accepted schema name', async () => { + const { reader, writer } = await fixture() + await writer.knex.schema.renameTable('knex_migrations', 'archive_migration_history') + await writer + .knex('archive_migration_history') + .orderBy('id', 'desc') + .limit(1) + .update({ name: 'x'.repeat(256) }) + reader.knex.client.config.migrations = { tableName: 'archive_migration_history', schemaName: 'main' } + const queries: string[] = [] + reader.knex.on('query', query => queries.push(query.sql)) + const source = await openKnexSnapshotArchiveSource(reader, identity) + expect(queries.some(sql => sql.includes('`main`.`archive_migration_history`'))).toBe(true) + expect(source.sourceSchema).toBe('x'.repeat(256)) + expect(source.isOpen).toBe(true) + await source.close() + await source.closed + expect(source.isOpen).toBe(false) + await writer.knex('archive_migration_history').update({ name: 'x' }) + const shortest = await openKnexSnapshotArchiveSource(reader, identity) + expect(shortest.sourceSchema).toBe('x') + await shortest.close() +}) + +test('a full page continues through its empty terminal page and preserves mainnet binding', async () => { + const { reader, writer, userId } = await fixture() + await writer.knex('settings').update({ chain: 'main' }) + await writer.knex('tx_labels_map').whereIn('txLabelId', [1, 3]).delete() + await writer.knex('tx_labels').where({ userId }).delete() + for (let index = 0; index < 256; index++) await writer.findOrInsertTxLabel(userId, `page-${index}`) + const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'main') + expect(manifest.binding.sourceStorage.chain).toBe('main') + expect(manifest.pages).toBe(15) + const store = new KnexSnapshotArchiveStore(writer.knex) + expect(await store.read(identity, manifest.archiveId, 8)).toMatchObject({ table: 'txLabels', rows: 128, done: false }) + expect(await store.read(identity, manifest.archiveId, 9)).toMatchObject({ table: 'txLabels', rows: 128, done: false }) + expect(await store.read(identity, manifest.archiveId, 10)).toMatchObject({ table: 'txLabels', rows: 0, done: true }) + await store.close(identity, manifest.archiveId) +}) + +test('oversized source rows and exhausted reservations fail closed', async () => { + const { reader, writer } = await fixture() + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { maxBytes: 4100 })).rejects.toThrow( + 'metadata' + ) + await writer + .knex('proven_txs') + .where({ provenTxId: 1 }) + .update({ rawTx: Buffer.alloc(200000) }) + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'test')).rejects.toThrow('maxBytes') + expect(await writer.knex('snapshot_archives')).toHaveLength(0) + expect(await writer.knex('snapshot_archive_pages')).toHaveLength(0) +}) + +test('encoding refusal cleans the reservation without returning an oversized frame', async () => { + const { reader, writer } = await fixture() + jest.spyOn(Transfer, 'encodeSyncTransfer').mockReturnValue(new Uint8Array(1048577)) + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'test')).rejects.toThrow('encoded page limit') + expect(await writer.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('capture bounds page work even if a source never reports completion', async () => { + const { reader, writer } = await fixture() + const source = await openKnexSnapshotArchiveSource(reader, identity) + const read = jest.fn(async () => ({ rows: [], payloadBytes: 0, done: false })) + jest.spyOn(ArchiveSource, 'openKnexSnapshotArchiveSource').mockResolvedValue({ ...source, readPage: read }) + const append = jest.spyOn(KnexSnapshotArchiveStore.prototype, 'append').mockResolvedValue() + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'test')).rejects.toThrow('page limit') + expect(read).toHaveBeenCalledTimes(4096) + expect(append).toHaveBeenCalledTimes(4096) + expect(source.isOpen).toBe(false) + expect(await writer.knex('snapshot_archives')).toHaveLength(0) +}) + +test('normalizes cross-realm dates and rejects malformed dates before staging them', async () => { + const { reader, writer } = await fixture() + for (const value of [runInNewContext('new Date("2026-01-01T00:00:00.000Z")'), new Date(NaN)]) { + const source = await openKnexSnapshotArchiveSource(reader, identity) + const readPage = source.readPage + jest.spyOn(ArchiveSource, 'openKnexSnapshotArchiveSource').mockResolvedValue({ + ...source, + readPage: async (...args) => { + const page = await readPage(...args) + for (const row of page.rows) row.created_at = value + return page + } + }) + const pending = captureKnexSnapshotArchive(reader, writer.knex, identity, 'test') + if (Number.isNaN(value.getTime())) await expect(pending).rejects.toThrow('invalid date') + else { + const manifest = await pending + const store = new KnexSnapshotArchiveStore(writer.knex) + const frame = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 0)).bytes) as { + rows: Array<{ created_at: string }> + } + expect(frame.rows[0].created_at).toBe('2026-01-01T00:00:00.000Z') + await store.close(identity, manifest.archiveId) + } + jest.restoreAllMocks() + } +}) + +test('failed source cleanup prevents sealing and interrupted staging cleanup keeps its reservation', async () => { + const { reader, writer } = await fixture() + const source = await openKnexSnapshotArchiveSource(reader, identity) + const failure = new Error('synthetic source close failure') + jest.spyOn(ArchiveSource, 'openKnexSnapshotArchiveSource').mockResolvedValue({ + ...source, + close: async () => { + await source.close() + throw failure + } + }) + await expect(captureKnexSnapshotArchive(reader, writer.knex, identity, 'test')).rejects.toBe(failure) + expect(await writer.knex('snapshot_archives')).toHaveLength(0) + jest.restoreAllMocks() + const callbackFailure = new Error('synthetic callback failure') + jest.spyOn(KnexSnapshotArchiveStore.prototype, 'close').mockRejectedValue(new Error('synthetic cleanup failure')) + await expect( + captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { + onProgress: () => { + throw callbackFailure + } + }) + ).rejects.toBe(callbackFailure) + expect(await writer.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 1 }) + expect((await writer.knex('snapshot_archives').first()).state).toBe('building') + jest.restoreAllMocks() + await writer.knex('snapshot_archives').update({ expiresAt: 0 }) + await new KnexSnapshotArchiveStore(writer.knex).reap() + expect(await writer.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0 }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts new file mode 100644 index 000000000..4be5089cd --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts @@ -0,0 +1,143 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { walletSnapshotSourceQuery } from '../KnexWalletReadSnapshot' +import type { WalletSnapshotTable } from '../WalletReadSnapshot' + +interface Reference { + table: WalletSnapshotTable + source: string + field: string + target: string + key: string + profile: boolean + optional?: boolean +} + +// Use exactly the same selected rows as the page reader. Global proofs are +// included only through this profile's transactions or proof requests. +const references: readonly Reference[] = [ + { + table: 'transactions', + source: 'transactions', + field: 'provenTxId', + target: 'proven_txs', + key: 'provenTxId', + profile: false, + optional: true + }, + { + table: 'provenTxReqs', + source: 'proven_tx_reqs', + field: 'provenTxId', + target: 'proven_txs', + key: 'provenTxId', + profile: false, + optional: true + }, + { + table: 'commissions', + source: 'commissions', + field: 'transactionId', + target: 'transactions', + key: 'transactionId', + profile: true + }, + { + table: 'outputs', + source: 'outputs', + field: 'transactionId', + target: 'transactions', + key: 'transactionId', + profile: true + }, + { + table: 'outputs', + source: 'outputs', + field: 'basketId', + target: 'output_baskets', + key: 'basketId', + profile: true, + optional: true + }, + { + table: 'outputs', + source: 'outputs', + field: 'spentBy', + target: 'transactions', + key: 'transactionId', + profile: true, + optional: true + }, + { + table: 'txLabelMaps', + source: 'tx_labels_map', + field: 'transactionId', + target: 'transactions', + key: 'transactionId', + profile: true + }, + { + table: 'txLabelMaps', + source: 'tx_labels_map', + field: 'txLabelId', + target: 'tx_labels', + key: 'txLabelId', + profile: true + }, + { + table: 'outputTagMaps', + source: 'output_tags_map', + field: 'outputId', + target: 'outputs', + key: 'outputId', + profile: true + }, + { + table: 'outputTagMaps', + source: 'output_tags_map', + field: 'outputTagId', + target: 'output_tags', + key: 'outputTagId', + profile: true + }, + { + table: 'certificateFields', + source: 'certificate_fields', + field: 'certificateId', + target: 'certificates', + key: 'certificateId', + profile: true + }, + { + table: 'certificateFields', + source: 'certificate_fields', + field: 'userId', + target: 'users', + key: 'userId', + profile: true + } +] + +/** + * Check relational closure in the caller's retained read transaction. Only a + * constant marker is returned for an invalid relation; blobs and full ID maps + * are never loaded. This does not parse or authenticate BRC-38/39 documents. + */ +export async function assertKnexSnapshotArchiveClosure(k: Knex, userId: number): Promise { + if (!Number.isSafeInteger(userId) || userId < 1) throw new WERR_INVALID_PARAMETER('userId', 'a positive safe ID') + await runInSeries(references, async reference => { + const column = `${reference.source}.${reference.field}` + const target = k(reference.target) + .select(k.raw('1')) + .whereRaw('?? = ??', [`${reference.target}.${reference.key}`, column]) + if (reference.profile) void target.where(`${reference.target}.userId`, userId) + const invalid = walletSnapshotSourceQuery(k, reference.table, userId) + .select(k.raw('1 AS invalid')) + .whereNotExists(target) + if (reference.optional === true) void invalid.whereNotNull(column) + if ((await invalid.first()) !== undefined) { + throw new WERR_INVALID_OPERATION('Snapshot source contains an incomplete or cross-profile relation') + } + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts new file mode 100644 index 000000000..3fabbbae2 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -0,0 +1,70 @@ +import { Random, Utils } from '@bsv/sdk' +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import type { StorageKnex } from '../../StorageKnex' +import { createKnexWalletSnapshotPageReader } from '../KnexWalletReadSnapshot' +import type { WalletReadSnapshot, WalletReadSnapshotOptions } from '../WalletReadSnapshot' +import { assertKnexSnapshotArchiveClosure } from './KnexSnapshotArchiveClosure' + +export interface SnapshotArchiveSource extends WalletReadSnapshot { + readonly sourceSchema: string + /** Verify profile relations using the same read view as the header and pages. */ + validateClosure: () => Promise +} + +async function sourceSchema(storage: StorageKnex, k: Knex): Promise { + const config = storage.knex.client.config.migrations + const query = k(config?.tableName ?? 'knex_migrations') + .select('name') + .orderBy('id', 'desc') + if (config?.schemaName !== undefined) void query.withSchema(config.schemaName) + const row: { name?: unknown } | undefined = await query.first() + if (typeof row?.name !== 'string' || row.name.length < 1 || row.name.length > 256) { + throw new WERR_INVALID_OPERATION('Snapshot source schema version is unavailable') + } + return row.name +} + +/** + * Internal SQL capture source. The caller supplies a dedicated reader provider, + * separate from the staging writer, and awaits close before releasing it. + * No capability is advertised by constructing this local source. + */ +export async function openKnexSnapshotArchiveSource( + storage: StorageKnex, + identityKey: string, + options: WalletReadSnapshotOptions = {} +): Promise { + if (typeof identityKey !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(identityKey)) { + throw new WERR_INVALID_PARAMETER('identityKey', 'a compressed public identity key') + } + const view = await storage.openReadSnapshot(options) + try { + const header = await view.read(async trx => { + const sourceStorage = await storage.readSettings(trx) + const user = await storage.findUserByIdentityKey(identityKey, trx) + if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') + return { sourceStorage, user, sourceSchema: await sourceSchema(storage, storage.toDb(trx)) } + }) + const userId = header.user.userId + const snapshotId = Utils.toHex(Random(32)) + return { + version: 1, + snapshotId, + ...header, + expiresAt: view.expiresAt, + get isOpen() { + return view.isOpen + }, + closed: view.closed, + close: view.close, + readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view), + validateClosure: async () => { + await view.read(trx => assertKnexSnapshotArchiveClosure(storage.toDb(trx), userId)) + } + } + } catch (error) { + await view.close().catch(() => undefined) + throw error + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts index a239b0faf..f51810ac5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts @@ -1,6 +1,13 @@ import fc from 'fast-check' import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { knex } from 'knex' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { decodeSyncTransfer } from '../../remoting/SyncTransfer' +import { captureKnexSnapshotArchive } from './captureKnexSnapshotArchive' import { addSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' import { KnexSnapshotArchiveStore, @@ -38,6 +45,96 @@ const binding: SnapshotArchiveBinding = { } const digest = (bytes: Uint8Array | string): string => createHash('sha256').update(bytes).digest('hex') +test('generated SQL captures match the selected profile or leave no readable state after cancellation', async () => { + const directory = await mkdtemp(join(tmpdir(), 'wallet-capture-property-')) + const open = () => + new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + }) + const writer = open() + const reader = open() + try { + await writer.knex.raw('PRAGMA journal_mode = WAL') + await writer.migrate('generated capture', 'generated-source') + await writer.makeAvailable() + await reader.makeAvailable() + const { user: first } = await writer.findOrInsertUser(identity) + const { user: second } = await writer.findOrInsertUser(other) + const store = new KnexSnapshotArchiveStore(writer.knex) + await fc.assert( + fc.asyncProperty( + fc.uniqueArray(fc.integer({ min: 0, max: 10000 }), { maxLength: 8 }), + fc.uniqueArray(fc.integer({ min: 0, max: 10000 }), { maxLength: 8 }), + fc.boolean(), + fc.integer({ min: -1, max: 14 }), + async (firstLabels, secondLabels, chooseSecond, cancelAfter) => { + await writer.knex('tx_labels').delete() + const rows = [ + ...firstLabels.map(label => ({ userId: first.userId, label: `first-${label}` })), + ...secondLabels.map(label => ({ userId: second.userId, label: `second-${label}` })) + ].map(row => ({ + ...row, + isDeleted: row.label.endsWith('0'), + created_at: date.toISOString(), + updated_at: date.toISOString() + })) + if (rows.length > 0) await writer.knex('tx_labels').insert(rows) + const selected = chooseSecond ? second : first + const expected = rows + .filter(row => row.userId === selected.userId) + .map(row => ({ label: row.label, isDeleted: row.isDeleted })) + const signal = new AbortController() + if (cancelAfter === 0) signal.abort() + const seen: number[] = [] + const pending = captureKnexSnapshotArchive(reader, writer.knex, selected.identityKey, 'test', { + signal: signal.signal, + onProgress: progress => { + seen.push(progress.pages) + if (progress.pages === cancelAfter) signal.abort() + progress.pages = -100 // A consumer cannot mutate the controller's cursor. + } + }) + if (cancelAfter >= 0 && cancelAfter <= 13) await expect(pending).rejects.toThrow('cancelled') + else { + const manifest = await pending + expect(manifest.pages).toBe(13) + expect(manifest.binding.user.identityKey).toBe(selected.identityKey) + const page = await store.read(selected.identityKey, manifest.archiveId, 8) + const frame = decodeSyncTransfer(page.bytes) as { + table: string + rows: Array<{ label: string; isDeleted: boolean }> + } + expect(frame.table).toBe('txLabels') + expect(frame.rows.map(row => ({ label: row.label, isDeleted: row.isDeleted }))).toEqual(expected) + await expect(store.read(chooseSecond ? identity : other, manifest.archiveId, 8)).rejects.toThrow( + 'unavailable' + ) + await store.close(selected.identityKey, manifest.archiveId) + } + expect(seen).toEqual(Array.from({ length: seen.length }, (_, index) => index + 1)) + expect(await writer.knex('snapshot_archives')).toHaveLength(0) + expect(await writer.knex('snapshot_archive_pages')).toHaveLength(0) + expect(await writer.knex('snapshot_archive_capacity').first()).toMatchObject({ + archives: 0, + reservedBytes: 0 + }) + } + ), + { seed: Number.isSafeInteger(requestedSeed) ? requestedSeed : 5442026 } + ) + } finally { + await reader.destroy() + await writer.destroy() + await rm(directory, { recursive: true, force: true }) + } +}, 120000) + // The model uses Node's independent hash implementation and only the persisted // public receipts; it does not call the store's hashing/accounting helpers. test('generated capture schedules preserve immutable receipts and reserve capacity through cancellation or expiry', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts new file mode 100644 index 000000000..e27f29014 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts @@ -0,0 +1,127 @@ +import type { Knex } from 'knex' +import type { Chain } from '../../../sdk/types' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import type { StorageKnex } from '../../StorageKnex' +import { encodeSyncTransfer } from '../../remoting/SyncTransfer' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import type { WalletSnapshotCursor, WalletSnapshotTable } from '../WalletReadSnapshot' +import { + KnexSnapshotArchiveStore, + snapshotArchiveLimits, + snapshotArchiveTables, + type SnapshotArchiveManifest, + type SnapshotArchiveWriter +} from './KnexSnapshotArchiveStore' +import { openKnexSnapshotArchiveSource } from './KnexSnapshotArchiveSource' + +export interface SnapshotArchiveCaptureOptions { + signal?: AbortSignal + lifetimeMs?: number + maxBytes?: number + /** Acknowledged staging progress only; no source IDs, row contents or secrets. */ + onProgress?: (progress: { pages: number; rows: number; bytes: number }) => void +} + +function cancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted === true) throw new WERR_INVALID_OPERATION('Snapshot archive capture was cancelled') +} + +function packedRows(rows: object[]): Array> { + return rows.map(row => + Object.fromEntries( + Object.entries(row).map(([key, value]) => { + if (value === null || typeof value !== 'object' || value instanceof Uint8Array) return [key, value] + // Retain SQL dates from other realms without asking the binary codec to + // classify their prototype. Standard wallet rows otherwise contain scalars. + const time = Date.prototype.getTime.call(value) + if (!Number.isFinite(time)) throw new WERR_INVALID_OPERATION('Snapshot source contains an invalid date') + return [key, new Date(time)] + }) + ) + ) +} + +/** + * Capture all thirteen raw standard tables from one retained SQL view. Pages + * use the existing binary frame, not BRC-38 canonical archive bytes. The reader + * must own a separate connection/pool from staging and foreground operations. + */ +export async function captureKnexSnapshotArchive( + reader: StorageKnex, + staging: Knex, + identityKey: string, + chain: Chain, + options: SnapshotArchiveCaptureOptions = {} +): Promise { + if (reader.knex === staging) { + throw new WERR_INVALID_PARAMETER('staging', 'a connection pool separate from the retained source reader') + } + if (chain !== 'main' && chain !== 'test') throw new WERR_INVALID_PARAMETER('chain', 'main or test') + // Detach caller-owned options before the first asynchronous boundary. + const { signal, lifetimeMs, maxBytes, onProgress } = options + cancelled(signal) + const store = new KnexSnapshotArchiveStore(staging) + await store.reap() + const source = await openKnexSnapshotArchiveSource(reader, identityKey, { signal, lifetimeMs }) + let writer: SnapshotArchiveWriter | undefined + let closed = false + try { + if (source.user.identityKey !== identityKey || source.sourceStorage.chain !== chain) { + throw new WERR_INVALID_OPERATION('Snapshot source does not match the requested profile and chain') + } + writer = await store.begin( + { + version: 1, + snapshotId: source.snapshotId, + sourceStorage: source.sourceStorage, + sourceSchema: source.sourceSchema, + user: source.user + }, + { lifetimeMs, maxBytes } + ) + await source.validateClosure() + const owner = writer + const progress = { pages: 0, rows: 0, bytes: 0 } + const captureTable = async (table: WalletSnapshotTable): Promise => { + let cursor: WalletSnapshotCursor | undefined + let done = false + while (!done) { + cancelled(signal) + if (progress.pages >= snapshotArchiveLimits.pages) { + throw new SnapshotResourceLimitError('Snapshot archive page limit exceeded') + } + // The conservative SQL charge bounds fetched payloads before encoding; + // framing/JSON escaping has a separate exact one-MiB admission below. + const page = await source.readPage(table, cursor, { maxRows: 128, maxBytes: 131072 }) + const bytes = encodeSyncTransfer({ version: 1, table, rows: packedRows(page.rows) }) + if (bytes.length > snapshotArchiveLimits.pageBytes) { + throw new SnapshotResourceLimitError('Snapshot archive encoded page limit exceeded') + } + cancelled(signal) + await store.append(owner, { sequence: progress.pages, table, rows: page.rows.length, done: page.done, bytes }) + progress.pages++ + progress.rows += page.rows.length + progress.bytes += bytes.length + onProgress?.({ ...progress }) + cancelled(signal) + cursor = page.cursor + done = page.done + } + } + await runInSeries(snapshotArchiveTables, captureTable) + await source.close() + closed = true + cancelled(signal) + const manifest = await store.seal(owner) + cancelled(signal) + return manifest + } catch (error) { + // Preserve the initiating error; interrupted cleanup keeps its reservation + // and is recovered by reap rather than exposing a partial result. + if (writer !== undefined) await store.close(identityKey, writer.archiveId).catch(() => undefined) + throw error + } finally { + if (!closed) await source.close().catch(() => undefined) + } +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index 296fffa71..8375f87ad 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -27,6 +27,10 @@ const { snapshotArchiveLimits } = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveStore.js') const { addSnapshotArchiveTables } = require('../../out/src/storage/schema/snapshotArchiveMigration.js') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { captureKnexSnapshotArchive } = require('../../out/src/storage/snapshot/archive/captureKnexSnapshotArchive.js') +const { decodeSyncTransfer } = require('../../out/src/storage/remoting/SyncTransfer.js') const open = () => knex({ client: 'mysql2', connection, pool: { min: 1, max: 1 } }) const database = open(), replica = open(), @@ -49,6 +53,124 @@ const binding = { }, user: { created_at: date, updated_at: date, userId: 7, identityKey: identity, activeStorage: 'source' } } + +async function captureFixture() { + const writer = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: open() }) + const reader = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: open() }) + const originalAppend = KnexSnapshotArchiveStore.prototype.append + try { + await writer.migrate('native capture source', 'native-source') + await writer.makeAvailable() + await reader.makeAvailable() + const { user } = await writer.findOrInsertUser(identity) + const { user: foreign } = await writer.findOrInsertUser(other) + const labels = Array.from({ length: 140 }, (_, index) => ({ + userId: user.userId, + label: `original-${index}`, + isDeleted: index % 7 === 0, + created_at: date, + updated_at: date + })) + await writer.knex('tx_labels').insert(labels) + await writer.findOrInsertTxLabel(foreign.userId, 'foreign label') + const tx = { + created_at: date, + updated_at: date, + status: 'completed', + isOutgoing: false, + satoshis: 1, + description: 'synthetic fixture' + } + const proof = await writer.insertProvenTx({ + created_at: date, + updated_at: date, + provenTxId: 0, + txid: 'a'.repeat(64), + height: 1, + index: 0, + merklePath: [4, 5, 255], + rawTx: [1, 2, 255], + blockHash: 'b'.repeat(64), + merkleRoot: 'c'.repeat(64) + }) + await writer.insertTransaction({ + ...tx, + transactionId: 0, + userId: user.userId, + provenTxId: proof, + txid: 'a'.repeat(64), + reference: 'owned' + }) + const foreignTransaction = await writer.insertTransaction({ + ...tx, + transactionId: 0, + userId: foreign.userId, + reference: 'foreign' + }) + await writer.knex('users').where({ userId: user.userId }).update({ activeStorage: 'historical selection' }) + let changedDuringCapture = false + KnexSnapshotArchiveStore.prototype.append = async function (owner, page) { + await originalAppend.call(this, owner, page) + if (page.sequence === 0) { + await writer + .knex('tx_labels') + .where({ userId: user.userId, label: 'original-0' }) + .update({ label: 'replacement' }) + await writer.knex('users').where({ userId: user.userId }).update({ activeStorage: 'replacement selection' }) + changedDuringCapture = true + } + } + const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test') + KnexSnapshotArchiveStore.prototype.append = originalAppend + assert.equal(changedDuringCapture, true) + assert.equal(manifest.pages, 14) + assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') + assert.equal(manifest.binding.user.activeStorage, 'historical selection') + assert.equal(manifest.binding.sourceSchema, '2026-09-30-002 add snapshot archive staging') + const store = new KnexSnapshotArchiveStore(writer.knex) + const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) + const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) + assert.equal(first.table, 'txLabels') + assert.equal(first.rows.length, 128) + assert.equal(second.rows.length, 12) + assert.deepEqual( + [...first.rows, ...second.rows].map(row => row.label), + labels.map(row => row.label) + ) + assert.equal(first.rows[0].created_at, date.toISOString()) + assert.equal(first.rows[0].isDeleted, true) + const proofPage = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 0)).bytes) + assert.deepEqual(proofPage.rows[0].rawTx, new Uint8Array([1, 2, 255])) + await store.close(identity, manifest.archiveId) + await writer.insertCommission({ + created_at: date, + updated_at: date, + commissionId: 0, + userId: user.userId, + transactionId: foreignTransaction, + satoshis: 1, + keyOffset: 'synthetic', + isRedeemed: false, + lockingScript: [1] + }) + await assert.rejects(captureKnexSnapshotArchive(reader, writer.knex, identity, 'test'), /relation/) + assert.equal((await writer.knex('snapshot_archive_capacity').first()).archives, 0) + return { + tables: 13, + pages: manifest.pages, + labels: 140, + pinnedConcurrentWrites: true, + originalPrimary: true, + originalSchema: true, + packedBinary: true, + crossProfileClosureRejected: true + } + } finally { + KnexSnapshotArchiveStore.prototype.append = originalAppend + await reader.destroy() + await writer.destroy() + } +} async function main() { try { const version = (await database.raw('SELECT VERSION() AS version'))[0][0].version @@ -99,6 +221,7 @@ async function main() { await addSnapshotArchiveTables(database) assert.equal(await database.schema.hasTable('snapshot_archive_pages'), true) assert.equal(Number((await database('snapshot_archive_capacity').first()).archives), 0) + const capture = await captureFixture() console.log( JSON.stringify({ version, @@ -110,7 +233,8 @@ async function main() { appendRollback: true, expiredPartialUnreadable: true, profileReservationRace: true, - idempotentPartialDdl: true + idempotentPartialDdl: true, + capture }) ) } finally { diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveFixtures.ts new file mode 100644 index 000000000..ed32eef48 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveFixtures.ts @@ -0,0 +1,134 @@ +import type { StorageKnex } from '../../src/storage/StorageKnex' + +const identity = '02' + '11'.repeat(32) +const date = '2026-01-01T00:00:00.000Z' +const timestamp = { created_at: date, updated_at: date } + +export async function seedArchiveClosure(source: StorageKnex, userId: number, otherId: number): Promise { + const k = source.knex + await k('output_baskets').del() + for (const id of [1, 2, 3]) { + await k('proven_txs').insert({ + ...timestamp, + provenTxId: id, + txid: String(id).repeat(64), + height: id, + index: 0, + merklePath: Buffer.from([id, 0, 255]), + rawTx: Buffer.from([id, 1, 255]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + await k('transactions').insert({ + ...timestamp, + transactionId: id, + userId: id === 2 ? otherId : userId, + provenTxId: id === 3 ? null : id, + status: 'completed', + reference: `tx-${id}`, + isOutgoing: true, + satoshis: 0, + description: `tx-${id}`, + txid: String(id).repeat(64), + rawTx: Buffer.from([id, 2, 255]), + inputBEEF: Buffer.from([id, 3, 255]) + }) + await k('proven_tx_reqs').insert({ + ...timestamp, + provenTxReqId: id, + provenTxId: id, + txid: String(id).repeat(64), + status: 'completed', + attempts: 0, + notified: true, + history: '{}', + notify: '{}', + rawTx: Buffer.from([id, 4, 255]), + wasBroadcast: true + }) + await k('output_baskets').insert({ + ...timestamp, + basketId: id, + userId: id === 2 ? otherId : userId, + name: `basket-${id}`, + isDeleted: id === 3 + }) + await k('outputs').insert({ + ...timestamp, + outputId: id, + userId: id === 2 ? otherId : userId, + transactionId: id, + basketId: id, + spendable: false, + change: true, + vout: 0, + satoshis: 1, + providedBy: 'you', + purpose: '', + type: 'P2PKH', + lockingScript: Buffer.from([id, 5, 255]) + }) + await k('commissions').insert({ + ...timestamp, + commissionId: id, + userId: id === 2 ? otherId : userId, + transactionId: id, + satoshis: 0, + keyOffset: 'offset', + isRedeemed: true, + lockingScript: Buffer.from([id, 6, 255]) + }) + await k('output_tags').insert({ + ...timestamp, + outputTagId: id, + userId: id === 2 ? otherId : userId, + tag: `tag-${id}`, + isDeleted: id === 3 + }) + await k('output_tags_map').insert({ ...timestamp, outputTagId: id, outputId: id, isDeleted: id === 3 }) + await k('tx_labels').insert({ + ...timestamp, + txLabelId: id, + userId: id === 2 ? otherId : userId, + label: `label-${id}`, + isDeleted: id === 3 + }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: id, transactionId: id, isDeleted: id === 3 }) + await k('certificates').insert({ + ...timestamp, + certificateId: id, + userId: id === 2 ? otherId : userId, + serialNumber: `serial-${id}`, + type: 'type', + certifier: identity, + subject: identity, + revocationOutpoint: 'a'.repeat(64) + '.0', + signature: 'signature', + isDeleted: id === 3 + }) + for (const fieldName of ['a', 'Z', 'é', '😀']) + await k('certificate_fields').insert({ + ...timestamp, + certificateId: id, + userId: id === 2 ? otherId : userId, + fieldName, + fieldValue: `value-${id}`, + masterKey: 'key' + }) + await k('sync_states').insert({ + ...timestamp, + syncStateId: id, + userId: id === 2 ? otherId : userId, + storageIdentityKey: `peer-${id}`, + storageName: `peer-${id}`, + status: 'unknown', + init: true, + refNum: `state-${id}`, + syncMap: '{}', + when: date + }) + } + // Composite positions must handle repeated first keys and preserve deleted mappings. + await k('output_tags_map').insert({ ...timestamp, outputTagId: 1, outputId: 3, isDeleted: true }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: 1, transactionId: 3, isDeleted: true }) +} diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 5abd93dcb..c0ba1fc4e 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -25,7 +25,8 @@ function workflowJobBlocks(workflow) { } function assertWalletMutationTimeout(job, defaultMinutes) { - const targets = '["wallet-retained-snapshot","wallet-snapshot-sync"]' + const targets = + '["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows"]' const expected = ` timeout-minutes: \${{ contains(fromJSON('${targets}'), matrix.target) && 90 || ${defaultMinutes} }}` assert.equal(job.source.match(/^ timeout-minutes: .+$/m)?.[0], expected) } diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index addfeec48..49ef9d04c 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 36) + assert.equal(result.summary.propertySuites, 38) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 37) - assert.equal(result.summary.mutationTargets, 36) + assert.equal(result.summary.mutationTargets, 38) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index eeee21a1e..49b96fed7 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -107,10 +107,14 @@ The subsequent internal SQL staging component adds shared immutable-page storage for remote snapshots. It binds profile/source metadata, reserves bounded logical capacity, commits pages and retry receipts atomically, and retains capacity until resumable cleanup finishes. Independent-connection SQLite/MySQL and actual SQLite -process-termination fixtures cover its persistence boundaries. The source capture -controller, complete semantic closure validation, authenticated client/server -integration, larger-wallet resource policy and measured physical storage costs -remain open. The migration does not expose or advertise a remote snapshot API. +process-termination fixtures cover its persistence boundaries. The local capture controller +now binds metadata and the migration version to the same SQL read view, checks +profile relationships without full ID maps, and captures all thirteen raw tables +into bounded binary frames. SQLite generated cases and a MySQL independent-writer +fixture exercise capture and cleanup. Canonical portable semantic validation, +authenticated client/server integration, larger-wallet resource policy and +measured physical storage costs remain open. The migration and local controller +do not expose or advertise a remote snapshot API. These checkpoints advance parts of S1/S2/P1/S4. They do not complete primary reconciliation, indexed identity/update predicates and commit-order high-water From d5b26780fcb001843ebb41e83dd4d858e2e10f37 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 09:55:41 -0700 Subject: [PATCH 049/127] fix(infra): refresh audited Axios resolutions without graph drift --- docs/reference/dependency-policy.md | 18 +++++++++++++++++- infra/message-box-server/package-lock.json | 8 ++++---- infra/uhrp-server-basic/package-lock.json | 8 ++++---- .../notifier/package-lock.json | 16 ++++++++-------- .../uhrp-server-cloud-bucket/package-lock.json | 8 ++++---- infra/wab/package-lock.json | 8 ++++---- 6 files changed, 41 insertions(+), 25 deletions(-) diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index 90dd2d414..2cf2c0b44 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -2,7 +2,7 @@ id: dependency-release-policy title: 'Dependency and Release Policy' kind: reference -version: '1.3.2' +version: '1.3.3' last_updated: '2026-09-30' last_verified: '2026-09-30' review_cadence_days: 30 @@ -149,6 +149,22 @@ The relevant upstream releases are [Engine.IO](https://github.com/socketio/socke [Undici](https://github.com/nodejs/undici/releases/tag/v6.28.1). Source reconciliation does not release packages or deploy service images. +The follow-up Axios audit correction selects `axios` 1.20.0 in the five existing +Message Box, UHRP basic, UHRP cloud, notifier and WAB locks. Only Axios resolution +and dependency metadata change; manifests, declared ranges, lock formats and all +other resolved versions are preserved. The already-selected `form-data` 4.0.6 +satisfies the raised dependency floor. Review of the +[1.19.0](https://github.com/axios/axios/releases/tag/v1.19.0) and +[1.20.0](https://github.com/axios/axios/releases/tag/v1.20.0) releases includes +configuration hardening, proxy behavior, cancellation and declaration changes. +The services use ordinary own-property request options; their frozen installs, +high/critical audits, builds where applicable and deterministic suites pass on +Node 24. Public workspace manifests and browser/mobile dependency graphs are +unchanged. Infrastructure locks are container/function build inputs, so this +correction needs no public npm version change or consumer migration. Hosted +Linux image and exact-head analysis gates remain required before promotion; +these local results do not establish deployed behavior. + The root workspace carries six narrow audited dependency overrides: - Jest 30.4.2 still constrains parts of its reporting and coverage graph to diff --git a/infra/message-box-server/package-lock.json b/infra/message-box-server/package-lock.json index 2255908eb..c151b1d3e 100644 --- a/infra/message-box-server/package-lock.json +++ b/infra/message-box-server/package-lock.json @@ -5373,14 +5373,14 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } diff --git a/infra/uhrp-server-basic/package-lock.json b/infra/uhrp-server-basic/package-lock.json index e732ba145..b0e59006b 100644 --- a/infra/uhrp-server-basic/package-lock.json +++ b/infra/uhrp-server-basic/package-lock.json @@ -4279,13 +4279,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } diff --git a/infra/uhrp-server-cloud-bucket/notifier/package-lock.json b/infra/uhrp-server-cloud-bucket/notifier/package-lock.json index 51219e06e..29b5f33bc 100644 --- a/infra/uhrp-server-cloud-bucket/notifier/package-lock.json +++ b/infra/uhrp-server-cloud-bucket/notifier/package-lock.json @@ -203,13 +203,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -1278,12 +1278,12 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "requires": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } diff --git a/infra/uhrp-server-cloud-bucket/package-lock.json b/infra/uhrp-server-cloud-bucket/package-lock.json index 2db21f16e..6ddf71301 100644 --- a/infra/uhrp-server-cloud-bucket/package-lock.json +++ b/infra/uhrp-server-cloud-bucket/package-lock.json @@ -4710,13 +4710,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } diff --git a/infra/wab/package-lock.json b/infra/wab/package-lock.json index 488459fa1..1feba8930 100644 --- a/infra/wab/package-lock.json +++ b/infra/wab/package-lock.json @@ -4420,13 +4420,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } From ff352a08eac64a84af68678d2f1241bc6d633f4e Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 10:05:15 -0700 Subject: [PATCH 050/127] fix(wallet): qualify bounded capture and isolated recovery fixtures --- governance/mutation-testing/targets.mjs | 20 ++++++----- .../wallet/wallet-toolbox/jest.config.cjs | 4 +-- .../archive/KnexSnapshotArchiveStore.test.ts | 2 +- .../archive/KnexSnapshotArchiveStore.ts | 13 ++++--- .../archive/captureKnexSnapshotArchive.ts | 7 ++-- .../test/storage/runSnapshotArchiveMysql.cjs | 35 ++++++++++++++----- .../test/storage/snapshotArchiveCrash.cjs | 26 ++++++++++---- .../test/storage/snapshotArchiveDocker.cjs | 12 +++++++ .../test/storage/snapshotArchiveMysql.cjs | 15 +++++--- .../test/utils/snapshotArchiveFixtures.ts | 27 +++++++------- 10 files changed, 111 insertions(+), 50 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.cjs diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index d04b1c4b5..8b903d178 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -181,13 +181,18 @@ function snapshotSyncMutationTargets(repositoryRoot) { 'wallet-snapshot-sync-destination': [], 'wallet-snapshot-sync-rows': [] } + const propertyTests = { + 'wallet-snapshot-sync': complete.propertyTest, + 'wallet-snapshot-sync-destination': + 'packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncDestination.property.test.ts', + 'wallet-snapshot-sync-rows': + 'packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.property.test.ts' + } for (const range of complete.mutate) { const file = range.replace(/:\d+(?:-\d+)?$/, '') - const name = destination.has(file) - ? 'wallet-snapshot-sync-destination' - : rows.has(file) - ? 'wallet-snapshot-sync-rows' - : 'wallet-snapshot-sync' + let name = 'wallet-snapshot-sync' + if (destination.has(file)) name = 'wallet-snapshot-sync-destination' + else if (rows.has(file)) name = 'wallet-snapshot-sync-rows' groups[name].push(range) } return Object.fromEntries( @@ -196,10 +201,7 @@ function snapshotSyncMutationTargets(repositoryRoot) { { ...complete, mutate, - propertyTest: - name === 'wallet-snapshot-sync' - ? complete.propertyTest - : `packages/wallet/wallet-toolbox/src/storage/snapshot/${name === 'wallet-snapshot-sync-destination' ? 'SnapshotSyncDestination' : 'SnapshotSyncRows'}.property.test.ts` + propertyTest: propertyTests[name] } ]) ) diff --git a/packages/wallet/wallet-toolbox/jest.config.cjs b/packages/wallet/wallet-toolbox/jest.config.cjs index 1cbc2440c..bd344f516 100644 --- a/packages/wallet/wallet-toolbox/jest.config.cjs +++ b/packages/wallet/wallet-toolbox/jest.config.cjs @@ -13,12 +13,12 @@ const getJestConfig = async () => { // Speed up by restricting to module (source files) extensions used. moduleFileExtensions: ['ts', 'js'], // excluded source files... - modulePathIgnorePatterns: ['out/src', 'out/test', '/dist/cjs/', '/\\.stryker-tmp/'], + modulePathIgnorePatterns: ['out/src', 'out/test', '/dist/cjs/', String.raw`/\.stryker-tmp/`], // Ignore generated children of this test root. Stryker runs with a sandbox // as its root, so an unanchored sandbox pattern would hide its own tests. // Module discovery retains the same boundary when package commands replace // testPathIgnorePatterns with their governed manual/live test policy. - testPathIgnorePatterns: ['/node_modules/', '/\\.stryker-tmp/'], + testPathIgnorePatterns: ['/node_modules/', String.raw`/\.stryker-tmp/`], // Default is 'node' testEnvironment: 'node', // default [ '**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)' ] diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index e2e1577ed..90f8b6a69 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -511,7 +511,7 @@ test('valid identifier and metadata boundary values remain admitted', async () = storageName: 'x'.repeat(65536 - base + binding.sourceStorage.storageName.length) } } - expect(new TextEncoder().encode(JSON.stringify(large)).length).toBe(65536) + expect(new TextEncoder().encode(JSON.stringify(large))).toHaveLength(65536) const writer = await store.begin(large, { maxBytes: 65536 + 4096 }) await store.close(identity, writer.archiveId) }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts index 60633088c..5fc239340 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts @@ -268,8 +268,7 @@ export class KnexSnapshotArchiveStore { .where({ archiveId: row.archiveId, sequence: input.sequence }) .first() if ( - prior === undefined || - prior.digest !== digest || + prior?.digest !== digest || prior.tableName !== input.table || prior.rows !== input.rows || Boolean(prior.done) !== input.done @@ -358,7 +357,8 @@ export class KnexSnapshotArchiveStore { const header = await this.ready(identityKey, archiveId) if (sequence >= header.nextSequence) throw unavailable() const page: PageRow | undefined = await this.knex('snapshot_archive_pages').where({ archiveId, sequence }).first() - if (page === undefined || hash(page.payload) !== page.digest) throw unavailable() + if (page === undefined) throw unavailable() + if (hash(page.payload) !== page.digest) throw unavailable() // Release/expiry during I/O cannot return a newly stale page. await this.ready(identityKey, archiveId) return { @@ -385,7 +385,10 @@ export class KnexSnapshotArchiveStore { // Bounded exact-key deletes do not hold source wallet locks or release the // capacity reservation early. A crash or concurrent closer can resume them. let hasPages = true - while (hasPages) { + function* pendingBatches(): Generator { + while (hasPages) yield undefined + } + await runInSeries(pendingBatches(), async () => { const rows: Array<{ sequence: number }> = await this.knex('snapshot_archive_pages') .select('sequence') .where({ archiveId }) @@ -400,7 +403,7 @@ export class KnexSnapshotArchiveStore { rows.map(row => row.sequence) ) .delete() - } + }) await this.knex.transaction(async trx => { const capacity = await this.capacity(trx) const row: ArchiveRow | undefined = await trx('snapshot_archives') diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts index e27f29014..7b90ccf94 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts @@ -86,7 +86,10 @@ export async function captureKnexSnapshotArchive( const captureTable = async (table: WalletSnapshotTable): Promise => { let cursor: WalletSnapshotCursor | undefined let done = false - while (!done) { + function* pendingPages(): Generator { + while (!done) yield undefined + } + await runInSeries(pendingPages(), async () => { cancelled(signal) if (progress.pages >= snapshotArchiveLimits.pages) { throw new SnapshotResourceLimitError('Snapshot archive page limit exceeded') @@ -107,7 +110,7 @@ export async function captureKnexSnapshotArchive( cancelled(signal) cursor = page.cursor done = page.done - } + }) } await runInSeries(snapshotArchiveTables, captureTable) await source.close() diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs index 30f57bd7e..cb42c856b 100644 --- a/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs @@ -1,11 +1,20 @@ // Disposable, loopback-only synthetic MySQL qualification. Never targets an // operator-supplied database, retains a volume, pulls an image, or builds one. const { execFileSync } = require('node:child_process') -const { randomUUID } = require('node:crypto') +const { randomBytes, randomUUID } = require('node:crypto') const { join } = require('node:path') const assert = require('node:assert/strict') +const executable = require('./snapshotArchiveDocker.cjs') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const secret = randomBytes(32).toString('hex') +const fixtureEnvironment = { ...process.env, MYSQL_ROOT_PASSWORD: secret, MYSQL_PWD: secret } const image = 'mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d' -const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +const docker = (...args) => + execFileSync(executable, ['--context', 'desktop-linux', ...args], { + encoding: 'utf8', + env: fixtureEnvironment, + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() async function main() { assert.equal(docker('context', 'show'), 'desktop-linux', 'This fixture requires the local Docker Desktop context') docker('image', 'inspect', image) @@ -27,7 +36,7 @@ async function main() { '--tmpfs', '/var/lib/mysql:rw,nosuid,nodev,size=512m', '--env', - 'MYSQL_ROOT_PASSWORD=synthetic-snapshot-fixture', + 'MYSQL_ROOT_PASSWORD', '--env', 'MYSQL_DATABASE=ts569_snapshot', '--publish', @@ -36,28 +45,38 @@ async function main() { ) try { const deadline = Date.now() + 60000 - for (;;) { + let ready = false + function* pendingReadiness() { + while (!ready) yield undefined + } + await runInSeries(pendingReadiness(), async () => { try { // TCP specifically excludes the entrypoint's temporary socket-only server. docker( 'exec', + '--env', + 'MYSQL_PWD', id, 'mysqladmin', '--protocol=tcp', '--host=127.0.0.1', '--user=root', - '--password=synthetic-snapshot-fixture', 'ping' ) - break + ready = true } catch (error) { if (Date.now() >= deadline) throw error await new Promise(resolve => setTimeout(resolve, 500)) } - } + }) const result = execFileSync(process.execPath, [join(__dirname, 'snapshotArchiveMysql.cjs')], { encoding: 'utf8', - env: { ...process.env, TS_STACK_SNAPSHOT_CONTAINER: name, TS_STACK_SNAPSHOT_CONTAINER_ID: id }, + env: { + ...process.env, + TS_STACK_SNAPSHOT_CONTAINER: name, + TS_STACK_SNAPSHOT_CONTAINER_ID: id, + TS_STACK_SNAPSHOT_MYSQL_SECRET: secret + }, timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs index a40097540..cea95306b 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs @@ -78,9 +78,10 @@ async function parent() { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ts569-archive-crash-')) let processHandle, database, exited try { - processHandle = spawn(process.execPath, [__filename, 'child', directory, phase], { + processHandle = spawn(process.execPath, [__filename, 'child', phase], { + cwd: directory, env: process.env, - stdio: ['ignore', 'ignore', 'pipe'] + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) let error = '' processHandle.stderr.on('data', chunk => { @@ -92,13 +93,16 @@ async function parent() { }) const deadline = Date.now() + 15000 let reachedBoundary = fs.existsSync(path.join(directory, 'boundary')) - while (!reachedBoundary) { + function* pendingBoundary() { + while (!reachedBoundary) yield undefined + } + await runInSeries(pendingBoundary(), async () => { if (processHandle.exitCode !== null || processHandle.signalCode !== null) throw new Error(error || 'Child stopped before boundary') assert(Date.now() < deadline, 'Boundary deadline: ' + phase) await new Promise(resolve => setTimeout(resolve, 20)) reachedBoundary = fs.existsSync(path.join(directory, 'boundary')) - } + }) assert.equal(fs.readFileSync(path.join(directory, 'boundary'), 'utf8'), phase) processHandle.kill('SIGKILL') const outcome = await exited @@ -112,7 +116,9 @@ async function parent() { assert.equal((await store.inspect(identity, writer.archiveId)).pages, 13) assert.equal((await store.read(identity, writer.archiveId, 12)).table, 'syncStates') } else await assert.rejects(store.inspect(identity, writer.archiveId), /unavailable/) - const committed = phase === 'after-append-commit' ? 1 : ready ? 13 : 0 + let committed = 0 + if (phase === 'after-append-commit') committed = 1 + else if (ready) committed = 13 assert.equal(before.nextSequence, committed) assert.equal(Number((await database('snapshot_archive_pages').count({ count: '*' }))[0].count), committed) if (phase === 'after-append-commit') { @@ -140,7 +146,7 @@ async function parent() { }) ) } finally { - if (processHandle && processHandle.exitCode === null && processHandle.signalCode === null) { + if (processHandle?.exitCode === null && processHandle.signalCode === null) { processHandle.kill('SIGKILL') await exited } @@ -149,7 +155,13 @@ async function parent() { } }) } -;(process.argv[2] === 'child' ? child(process.argv[3], process.argv[4]) : parent()).catch(error => { +async function main() { + if (process.argv[2] === 'child') { + assert.equal(typeof process.send, 'function', 'Child execution requires its fixture parent') + await child(process.cwd(), process.argv[3]) + } else await parent() +} +main().catch(error => { console.error(error) process.exitCode = 1 }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.cjs new file mode 100644 index 000000000..563b05067 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.cjs @@ -0,0 +1,12 @@ +const { existsSync } = require('node:fs') + +// Fixture executables use known installation paths rather than searching PATH. +const executable = [ + '/Applications/Docker.app/Contents/Resources/bin/docker', + '/usr/bin/docker', + '/usr/local/bin/docker', + '/opt/homebrew/bin/docker' +].find(candidate => existsSync(candidate)) + +if (executable === undefined) throw new Error('Install Docker Desktop at a supported local installation path') +module.exports = executable diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index 8375f87ad..abbd758dd 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -2,21 +2,28 @@ const assert = require('node:assert/strict') const { execFileSync } = require('node:child_process') const { knex } = require('knex') const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const executable = require('./snapshotArchiveDocker.cjs') const container = process.env.TS_STACK_SNAPSHOT_CONTAINER const expectedId = process.env.TS_STACK_SNAPSHOT_CONTAINER_ID -if (!container || !expectedId) throw new Error('Use the bounded local fixture launcher') -const actual = JSON.parse(execFileSync('docker', ['inspect', container], { encoding: 'utf8' }))[0] +const secret = process.env.TS_STACK_SNAPSHOT_MYSQL_SECRET +if (!container || !expectedId || !secret) throw new Error('Use the bounded local fixture launcher') +const actual = JSON.parse( + execFileSync(executable, ['--context', 'desktop-linux', 'inspect', container], { encoding: 'utf8' }) +)[0] assert.equal(actual.Id, expectedId) assert.equal(actual.Config.Labels['network-ops.fixture'], 'ts-stack-544-durable') assert.equal(actual.Config.Image, 'mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d') const port = Number( - execFileSync('docker', ['port', expectedId, '3306/tcp'], { encoding: 'utf8' }).trim().split(':').at(-1) + execFileSync(executable, ['--context', 'desktop-linux', 'port', expectedId, '3306/tcp'], { encoding: 'utf8' }) + .trim() + .split(':') + .at(-1) ) const connection = { host: '127.0.0.1', port, user: 'root', - password: 'synthetic-snapshot-fixture', + password: secret, database: 'ts569_snapshot', timezone: 'Z' } diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveFixtures.ts index ed32eef48..bebcfaaec 100644 --- a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveFixtures.ts +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveFixtures.ts @@ -1,4 +1,5 @@ import type { StorageKnex } from '../../src/storage/StorageKnex' +import { runInSeries } from '../../src/utility/runInSeries' const identity = '02' + '11'.repeat(32) const date = '2026-01-01T00:00:00.000Z' @@ -7,7 +8,8 @@ const timestamp = { created_at: date, updated_at: date } export async function seedArchiveClosure(source: StorageKnex, userId: number, otherId: number): Promise { const k = source.knex await k('output_baskets').del() - for (const id of [1, 2, 3]) { + await runInSeries([1, 2, 3], async id => { + const profileId = id === 2 ? otherId : userId await k('proven_txs').insert({ ...timestamp, provenTxId: id, @@ -22,7 +24,7 @@ export async function seedArchiveClosure(source: StorageKnex, userId: number, ot await k('transactions').insert({ ...timestamp, transactionId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, provenTxId: id === 3 ? null : id, status: 'completed', reference: `tx-${id}`, @@ -49,14 +51,14 @@ export async function seedArchiveClosure(source: StorageKnex, userId: number, ot await k('output_baskets').insert({ ...timestamp, basketId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, name: `basket-${id}`, isDeleted: id === 3 }) await k('outputs').insert({ ...timestamp, outputId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, transactionId: id, basketId: id, spendable: false, @@ -71,7 +73,7 @@ export async function seedArchiveClosure(source: StorageKnex, userId: number, ot await k('commissions').insert({ ...timestamp, commissionId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, transactionId: id, satoshis: 0, keyOffset: 'offset', @@ -81,7 +83,7 @@ export async function seedArchiveClosure(source: StorageKnex, userId: number, ot await k('output_tags').insert({ ...timestamp, outputTagId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, tag: `tag-${id}`, isDeleted: id === 3 }) @@ -89,7 +91,7 @@ export async function seedArchiveClosure(source: StorageKnex, userId: number, ot await k('tx_labels').insert({ ...timestamp, txLabelId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, label: `label-${id}`, isDeleted: id === 3 }) @@ -97,7 +99,7 @@ export async function seedArchiveClosure(source: StorageKnex, userId: number, ot await k('certificates').insert({ ...timestamp, certificateId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, serialNumber: `serial-${id}`, type: 'type', certifier: identity, @@ -106,19 +108,20 @@ export async function seedArchiveClosure(source: StorageKnex, userId: number, ot signature: 'signature', isDeleted: id === 3 }) - for (const fieldName of ['a', 'Z', 'é', '😀']) + await runInSeries(['a', 'Z', 'é', '😀'], async fieldName => { await k('certificate_fields').insert({ ...timestamp, certificateId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, fieldName, fieldValue: `value-${id}`, masterKey: 'key' }) + }) await k('sync_states').insert({ ...timestamp, syncStateId: id, - userId: id === 2 ? otherId : userId, + userId: profileId, storageIdentityKey: `peer-${id}`, storageName: `peer-${id}`, status: 'unknown', @@ -127,7 +130,7 @@ export async function seedArchiveClosure(source: StorageKnex, userId: number, ot syncMap: '{}', when: date }) - } + }) // Composite positions must handle repeated first keys and preserve deleted mappings. await k('output_tags_map').insert({ ...timestamp, outputTagId: 1, outputId: 3, isDeleted: true }) await k('tx_labels_map').insert({ ...timestamp, txLabelId: 1, transactionId: 3, isDeleted: true }) From 34956f4c5737a335dd2cdb89b790f1010a1cb1c0 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 10:45:46 -0700 Subject: [PATCH 051/127] feat(wallet): verify bounded snapshot receipt directories --- docs/guides/wallet-sync-reliability.md | 23 ++ docs/reference/package-api-migrations.md | 76 ++-- governance/mutation-testing/policy.json | 10 + governance/mutation-testing/targets.mjs | 20 + governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 13 + packages/wallet/wallet-toolbox/README.md | 5 + packages/wallet/wallet-toolbox/package.json | 2 +- .../KnexSnapshotArchiveCapture.test.ts | 12 +- .../archive/KnexSnapshotArchiveStore.test.ts | 65 +++ .../archive/KnexSnapshotArchiveStore.ts | 111 +++--- .../snapshot/archive/SnapshotArchive.ts | 66 +++ .../SnapshotArchiveDirectory.property.test.ts | 68 ++++ .../archive/SnapshotArchiveDirectory.test.ts | 376 ++++++++++++++++++ .../archive/SnapshotArchiveDirectory.ts | 275 +++++++++++++ .../utils/snapshotArchiveDirectoryFixtures.ts | 85 ++++ scripts/test-governance.test.mjs | 4 +- specs/wallet/sync-portability-program.md | 10 + 18 files changed, 1117 insertions(+), 108 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchive.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.ts create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotArchiveDirectoryFixtures.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 54cc782da..6773eae1e 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -370,6 +370,29 @@ fails, the reservation remains available for expiry/reaping recovery. Callers must supply independent reader/staging pools and keep schema migrations outside active capture windows. No RPC route or advertised capability is added. +The store's internal `directory` read returns bounded receipt metadata without +selecting payloads or returning writer credentials. It preserves the exact +original binding JSON bytes used to seed the hash chain. A replacement server +connection can read the same sealed directory; profile authorization and expiry +are checked again after loading its receipts. + +`verifySnapshotArchiveDirectory` validates the expected profile, network, +original storage identity and optional resumed root/schema, exact metadata +fields, lifetime, row totals and the complete ordered receipt chain. All thirteen +table boundaries must be present. It returns detached receipts and a fixed-size +table index, so callers can verify arbitrary-table pages without downloading +earlier payloads. `verifySnapshotArchivePage` copies bounded typed bytes and +checks their digest and metadata against an already verified receipt. Returned +dates remain JavaScript Date objects: freezing their containing objects does not +freeze their internal time values. An eventual adapter must keep its verified +binding private and give callers fresh metadata copies. + +The maximum accepted directory fits below a one-MiB metadata budget. Transport +integration must separately limit incoming envelope bytes before parsing. These +checks establish inclusion under the received root, not independent trust in the +source's data or proof semantics. The wire adapter, durable creation receipts, +capability negotiation and actual authenticated HTTP lifecycle remain incomplete. + The initial policy allows at most eight handles and 128 MiB of logical reserved storage globally, one handle and 32 MiB per profile, 1 MiB per page, 1,000 rows per page and 4,096 pages. Metadata is at most 64 KiB. A reservation includes encoded diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 753a7d46c..ed49b230d 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 39f48f9da..f383f0fa8 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -107,6 +107,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-snapshot-remote-directory", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts", + "risk": "critical", + "boundary": "Wallet authenticated snapshot receipt directories, original binding bytes, complete table hash chains and page inclusion", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "overlay-linkage", "manifest": "packages/overlays/topics/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 8b903d178..0724c0fce 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -425,6 +425,7 @@ export function buildMutationTargets(repositoryRoot) { 'packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts', mutate: [ 'src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts', + 'src/storage/snapshot/archive/SnapshotArchive.ts', 'src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts', 'src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts', 'src/storage/snapshot/archive/captureKnexSnapshotArchive.ts', @@ -439,6 +440,25 @@ export function buildMutationTargets(repositoryRoot) { } }) }, + 'wallet-snapshot-remote-directory': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts', + mutate: ['src/storage/snapshot/archive/SnapshotArchiveDirectory.ts'], + ...jestTarget( + 'jest.config.cjs', + ['/src/storage/snapshot/archive/SnapshotArchiveDirectory*.test.ts'], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + }, ...snapshotSyncMutationTargets(repositoryRoot), 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 3f5721ff1..3acf73ee7 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,8 +217,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 8352f243f..ec3a3ff42 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -522,6 +522,19 @@ "Capacity remains reserved through interrupted cleanup and is released exactly once after every page is deleted.", "A complete SQL capture contains the selected profile\u2019s original rows; cancellation removes all staged pages and reservations without activating wallet data." ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet authenticated snapshot receipt directories, original binding bytes, complete table hash chains and page inclusion", + "target": "Generated complete table ranges, independent receipt hashes and changed-byte rejection", + "invariants": [ + "Every bounded receipt chain preserves all thirteen table positions and row totals.", + "Arbitrary table pages match their previously verified receipt before decoding.", + "Changed payload, receipt or previously pinned root bytes reject.", + "Caller mutation does not alter verified receipt positions or metadata." + ] } ], "exclusions": [ diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 795909ebb..0eb7e017b 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -51,6 +51,11 @@ capacity. IndexedDB and remote RPC do not expose retained views. See the The SQL candidate also includes [bounded archive staging](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#shared-sql-snapshot-staging-unpublished-internal-component) for the ongoing remote snapshot implementation. This internal component does not add an authenticated export endpoint or a browser/mobile database adapter. +Its metadata-only receipt directory binds every page and all thirteen table +positions to the archive root before arbitrary-table reads; each payload is +checked against that verified directory. The original binding JSON bytes are +preserved as the hash preimage. This verifies transport integrity, not the source's +honesty or portable transaction/proof semantics. The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index e2d8ac963..bd13c2969 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index a2c718a55..05af1dd12 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -14,6 +14,7 @@ import { openKnexSnapshotArchiveSource } from './KnexSnapshotArchiveSource' import { assertKnexSnapshotArchiveClosure } from './KnexSnapshotArchiveClosure' import { captureKnexSnapshotArchive } from './captureKnexSnapshotArchive' import { KnexSnapshotArchiveStore, snapshotArchiveTables } from './KnexSnapshotArchiveStore' +import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage } from './SnapshotArchiveDirectory' const identity = '02' + '11'.repeat(32) const foreign = '03' + '22'.repeat(32) @@ -77,10 +78,19 @@ test('captures all thirteen tables with original metadata, packed bytes and prof expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) expect(manifest.pages).toBe(13) const store = new KnexSnapshotArchiveStore(writer.knex) + const verified = verifySnapshotArchiveDirectory(await store.directory(identity, manifest.archiveId), { + identityKey: identity, + chain: 'test', + sourceStorageIdentityKey: 'original-source', + archiveId: manifest.archiveId, + digest: manifest.digest, + sourceSchema: SNAPSHOT_ARCHIVE_MIGRATION + }) + expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} for (const [sequence, table] of snapshotArchiveTables.entries()) { const page = await store.read(identity, manifest.archiveId, sequence) - const frame = decodeSyncTransfer(page.bytes) as { + const frame = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[sequence])) as { version: number table: string rows: Array> diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index 90f8b6a69..6d8bfda9c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { knex, type Knex } from 'knex' import { addSnapshotArchiveTables, removeSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' +import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage } from './SnapshotArchiveDirectory' import { KnexSnapshotArchiveStore, snapshotArchiveTables, @@ -118,6 +119,70 @@ test('mainnet source metadata survives sealing and cross-connection reads', asyn await expect(store.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') }) +test('a replacement server returns verified inclusion metadata without selecting payloads or writer credentials', async () => { + const { db, peer, store } = await fixture() + const writer = await store.begin(binding) + await expect(store.directory(identity, writer.archiveId)).rejects.toThrow('unavailable') + const manifest = await complete(store, writer) + const second = new KnexSnapshotArchiveStore(peer) + const queries: string[] = [] + const observe = (query: { sql: string }) => queries.push(query.sql) + peer.on('query', observe) + const directory = await second.directory(identity, writer.archiveId) + peer.off('query', observe) + const pageQueries = queries.filter(sql => sql.includes('snapshot_archive_pages')) + expect(pageQueries).toHaveLength(1) + expect(pageQueries[0]).not.toMatch(/payload|\*/) + expect(pageQueries[0]).toContain('limit ?') + expect(JSON.stringify(directory)).not.toContain(writer.writerToken) + expect(directory.bindingJson).toBe((await db('snapshot_archives').first()).binding) + const verified = verifySnapshotArchiveDirectory(directory, { + identityKey: identity, + chain: 'test', + sourceStorageIdentityKey: 'source', + archiveId: manifest.archiveId, + digest: manifest.digest + }) + expect(verified.manifest).toEqual(manifest) + expect(verified.tables.syncStates).toEqual({ first: 12, pages: 1, rows: 0 }) + const last = await second.read(identity, writer.archiveId, verified.tables.syncStates.first) + expect(verifySnapshotArchivePage(last, verified.receipts[12])).toEqual(bytes) + directory.receipts[0].digest = 'f'.repeat(64) + expect((await second.directory(identity, writer.archiveId)).receipts[0].digest).not.toBe('f'.repeat(64)) + await expect(second.directory(other, writer.archiveId)).rejects.toThrow('unavailable') + await store.close(identity, writer.archiveId) + await expect(second.directory(identity, writer.archiveId)).rejects.toThrow('unavailable') +}) + +test('a directory read refuses a missing staged receipt', async () => { + const { db, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + await db('snapshot_archive_pages').where({ archiveId: writer.archiveId, sequence: 7 }).delete() + await expect(store.directory(identity, writer.archiveId)).rejects.toThrow('unavailable') +}) + +test.each(['closing', 'expired'])('a directory read rechecks %s after loading receipts', async state => { + const { db, peer, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + const original = db.client.query.bind(db.client) + const intercept = jest.spyOn(db.client, 'query').mockImplementation(async (connection, query, ...rest) => { + const result = await original(connection, query, ...rest) + if (typeof query !== 'string' && query.sql.includes('from `snapshot_archive_pages`')) { + await peer('snapshot_archives') + .where({ archiveId: writer.archiveId }) + .update(state === 'closing' ? { state: 'closing' } : { expiresAt: 0 }) + } + return result + }) + try { + await expect(store.directory(identity, writer.archiveId)).rejects.toThrow('unavailable') + } finally { + intercept.mockRestore() + } +}) + test('profile and internal capture ownership remain independent authorization boundaries', async () => { const { db, store } = await fixture() const writer = await store.begin(binding) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts index 5fc239340..0a4ed08d6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts @@ -2,73 +2,26 @@ import { Hash, Random, Utils } from '@bsv/sdk' import type { Knex } from 'knex' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' -import type { TableSettings, TableUser } from '../../schema/tables' import type { WalletSnapshotTable } from '../WalletReadSnapshot' import { runInSeries } from '../../../utility/runInSeries' +import { snapshotArchiveEncoding, type SnapshotArchiveDirectory } from './SnapshotArchiveDirectory' -/** Persistent transport staging; these auxiliary records never enter BRC-38. */ -export const snapshotArchiveTables = Object.freeze([ - 'provenTxs', - 'provenTxReqs', - 'outputBaskets', - 'transactions', - 'commissions', - 'outputs', - 'outputTags', - 'outputTagMaps', - 'txLabels', - 'txLabelMaps', - 'certificates', - 'certificateFields', - 'syncStates' -] as const satisfies readonly WalletSnapshotTable[]) - -export const snapshotArchiveLimits = Object.freeze({ - archives: 8, - totalBytes: 128 * 1024 * 1024, - archiveBytes: 32 * 1024 * 1024, - pageBytes: 1024 * 1024, - pages: 4096, - rowsPerPage: 1000, - lifetimeMs: 3600000, - bindingBytes: 65536, - headerCharge: 4096, - pageCharge: 512 -}) - -export interface SnapshotArchiveBinding { - version: 1 - /** The original retained source view, never a replacement replica checkpoint. */ - snapshotId: string - sourceStorage: TableSettings - sourceSchema: string - user: TableUser -} - -export interface SnapshotArchiveWriter { - archiveId: string - /** Internal capture ownership only. Never return this token to an RPC caller. */ - writerToken: string -} - -export interface SnapshotArchiveManifest { - version: 1 - archiveId: string - binding: SnapshotArchiveBinding - expiresAt: number - pages: number - rows: number - digest: string -} - -export interface SnapshotArchivePage { - sequence: number - table: WalletSnapshotTable - rows: number - done: boolean - bytes: Uint8Array - digest: string -} +import { + snapshotArchiveTables, + snapshotArchiveLimits, + type SnapshotArchiveBinding, + type SnapshotArchiveWriter, + type SnapshotArchiveManifest, + type SnapshotArchivePage +} from './SnapshotArchive' +export { + snapshotArchiveTables, + snapshotArchiveLimits, + type SnapshotArchiveBinding, + type SnapshotArchiveWriter, + type SnapshotArchiveManifest, + type SnapshotArchivePage +} from './SnapshotArchive' interface ArchiveRow { archiveId: string @@ -352,6 +305,36 @@ export class KnexSnapshotArchiveStore { return this.manifest(await this.ready(identityKey, archiveId)) } + /** Bounded inclusion metadata, without fetching any staged row payloads. */ + async directory(identityKey: string, archiveId: string): Promise { + const header = await this.ready(identityKey, archiveId) + const pages: Array> = await this.knex('snapshot_archive_pages') + .select('sequence', 'tableName', 'rows', 'done', 'digest') + .where({ archiveId }) + .orderBy('sequence') + .limit(snapshotArchiveLimits.pages) + if (pages.length !== header.nextSequence) throw unavailable() + // A concurrent close or expiry must not publish a newly stale directory. + await this.ready(identityKey, archiveId) + return { + version: 1, + encoding: snapshotArchiveEncoding, + archiveId, + expiresAt: Number(header.expiresAt), + pages: header.nextSequence, + rows: Number(header.rows), + digest: header.digest, + bindingJson: header.binding, + receipts: pages.map(page => ({ + sequence: page.sequence, + table: page.tableName, + rows: page.rows, + done: Boolean(page.done), + digest: page.digest + })) + } + } + async read(identityKey: string, archiveId: string, sequence: number): Promise { integer(sequence, 0, snapshotArchiveLimits.pages - 1, 'sequence') const header = await this.ready(identityKey, archiveId) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchive.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchive.ts new file mode 100644 index 000000000..10ab5c7c5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchive.ts @@ -0,0 +1,66 @@ +import type { TableSettings, TableUser } from '../../schema/tables' +import type { WalletSnapshotTable } from '../WalletReadSnapshot' + +/** Persistent transport staging; these auxiliary records never enter BRC-38. */ +export const snapshotArchiveTables = Object.freeze([ + 'provenTxs', + 'provenTxReqs', + 'outputBaskets', + 'transactions', + 'commissions', + 'outputs', + 'outputTags', + 'outputTagMaps', + 'txLabels', + 'txLabelMaps', + 'certificates', + 'certificateFields', + 'syncStates' +] as const satisfies readonly WalletSnapshotTable[]) + +export const snapshotArchiveLimits = Object.freeze({ + archives: 8, + totalBytes: 128 * 1024 * 1024, + archiveBytes: 32 * 1024 * 1024, + pageBytes: 1024 * 1024, + pages: 4096, + rowsPerPage: 1000, + lifetimeMs: 3600000, + bindingBytes: 65536, + headerCharge: 4096, + pageCharge: 512 +}) + +export interface SnapshotArchiveBinding { + version: 1 + /** The original retained source view, never a replacement replica checkpoint. */ + snapshotId: string + sourceStorage: TableSettings + sourceSchema: string + user: TableUser +} + +export interface SnapshotArchiveWriter { + archiveId: string + /** Internal capture ownership only. Never return this token to an RPC caller. */ + writerToken: string +} + +export interface SnapshotArchiveManifest { + version: 1 + archiveId: string + binding: SnapshotArchiveBinding + expiresAt: number + pages: number + rows: number + digest: string +} + +export interface SnapshotArchivePage { + sequence: number + table: WalletSnapshotTable + rows: number + done: boolean + bytes: Uint8Array + digest: string +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts new file mode 100644 index 000000000..0f3185138 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts @@ -0,0 +1,68 @@ +import fc from 'fast-check' +import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage } from './SnapshotArchiveDirectory' +import { expected, fixture, hash, now, rehash, tables } from '../../../../test/utils/snapshotArchiveDirectoryFixtures' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +function generatedDirectory(pageCounts: number[], seed: Uint8Array) { + const { directory } = fixture() + const payloads: Uint8Array[] = [] + const ranges: Array<{ first: number; pages: number; rows: number }> = [] + directory.receipts = [] + directory.rows = 0 + for (const [tableIndex, table] of tables.entries()) { + const range = { first: payloads.length, pages: pageCounts[tableIndex], rows: 0 } + for (let page = 0; page < range.pages; page++) { + const bytes = new Uint8Array([...seed, tableIndex, page]) + const done = page === range.pages - 1 + const rows = done ? seed[0] % 10 : 1 + seed[0] + directory.receipts.push({ sequence: payloads.length, table, rows, done, digest: hash(bytes) }) + payloads.push(bytes) + range.rows += rows + } + directory.rows += range.rows + ranges.push(range) + } + directory.pages = payloads.length + rehash(directory) + return { directory, payloads, ranges } +} + +test('generated receipt chains support arbitrary table reads while rejecting changed page and manifest bytes', () => { + fc.assert( + fc.property( + fc.array(fc.integer({ min: 1, max: 4 }), { minLength: 13, maxLength: 13 }), + fc.uint8Array({ minLength: 1, maxLength: 16 }), + fc.integer({ min: 0, max: 1000 }), + (pageCounts, seed, selection) => { + const { directory, payloads, ranges } = generatedDirectory(pageCounts, seed) + const verified = verifySnapshotArchiveDirectory(directory, { ...expected, digest: directory.digest }, now) + expect(verified.manifest.rows).toBe(ranges.reduce((total, range) => total + range.rows, 0)) + for (const [index, table] of tables.entries()) expect(verified.tables[table]).toEqual(ranges[index]) + const selected = selection % payloads.length + const original = payloads[selected] + const page = { ...directory.receipts[selected], bytes: original } + expect(verifySnapshotArchivePage(page, verified.receipts[selected])).toEqual(original) + const changed = new Uint8Array(original) + changed[0] ^= 1 + expect(() => verifySnapshotArchivePage({ ...page, bytes: changed }, verified.receipts[selected])).toThrow() + directory.receipts[selected].digest = hash(changed) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow() + rehash(directory) + expect(() => + verifySnapshotArchiveDirectory(directory, { ...expected, digest: verified.manifest.digest }, now) + ).toThrow() + directory.receipts[selected].rows++ + expect(verified.receipts[selected].rows).toBe(page.rows) + } + ) + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.test.ts new file mode 100644 index 000000000..26c25b69f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.test.ts @@ -0,0 +1,376 @@ +import { + snapshotArchiveDirectoryBytes, + verifySnapshotArchiveDirectory, + verifySnapshotArchivePage +} from './SnapshotArchiveDirectory' +import { + expected, + fixture, + hash, + now, + rehash, + sourceStorageIdentityKey +} from '../../../../test/utils/snapshotArchiveDirectoryFixtures' + +test.each([null, undefined, 1, 'directory', []])('rejects a non-record directory %p', value => { + expect(() => verifySnapshotArchiveDirectory(value, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test('uses the current clock by default and accepts the exact maximum lifetime', () => { + const { directory } = fixture() + directory.expiresAt = now + 3600000 + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + try { + expect(verifySnapshotArchiveDirectory(directory, expected).manifest.expiresAt).toBe(now + 3600000) + } finally { + clock.mockRestore() + } +}) + +test.each(['not-a-date', '2026-09-30', '2026-09-30T00:00:00+00:00'])('rejects noncanonical source date %s', value => { + const { directory, binding } = fixture() + binding.sourceStorage.created_at = value + directory.bindingJson = JSON.stringify(binding) + rehash(directory) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test.each(['version', 'database', 'script-limit', 'user-id', 'schema-size'])('rejects invalid binding %s', variant => { + const { directory, binding } = fixture() + if (variant === 'version') binding.version = 2 + if (variant === 'database') binding.sourceStorage.dbtype = 'IndexedDB' + if (variant === 'script-limit') binding.sourceStorage.maxOutputScript = -1 + if (variant === 'user-id') binding.user.userId = 0 + if (variant === 'schema-size') binding.sourceSchema = 'x'.repeat(257) + directory.bindingJson = JSON.stringify(binding) + rehash(directory) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test('accepts original MySQL metadata with empty permitted labels', () => { + const { directory, binding } = fixture() + binding.sourceStorage.dbtype = 'MySQL' + binding.sourceStorage.storageName = '' + binding.user.activeStorage = '' + binding.sourceStorage.maxOutputScript = 0 + directory.bindingJson = JSON.stringify(binding) + rehash(directory) + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + expect(verified.manifest.binding.sourceStorage).toMatchObject({ + dbtype: 'MySQL', + storageName: '', + maxOutputScript: 0 + }) + expect(verified.manifest.binding.user.activeStorage).toBe('') +}) + +test('rejects oversized UTF-8 metadata before its JSON is interpreted', () => { + const { directory, binding } = fixture() + binding.sourceStorage.storageName = 'é'.repeat(40000) + directory.bindingJson = JSON.stringify(binding) + expect(directory.bindingJson.length).toBeLessThan(65536) + expect(new TextEncoder().encode(directory.bindingJson).length).toBeGreaterThan(65536) + rehash(directory) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test('rejects malformed UTF-16 metadata rather than hashing replacement text', () => { + const { directory } = fixture() + directory.bindingJson = directory.bindingJson.replace('Original source', '\ud800') + rehash(directory) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test('all declared fields must be own enumerable data', () => { + const { directory } = fixture() + const hidden = { ...directory } + Object.defineProperty(hidden, 'digest', { value: directory.digest, enumerable: false }) + expect(() => verifySnapshotArchiveDirectory(hidden, expected, now)).toThrow('Invalid snapshot archive directory') + const renamed = { ...directory, replacement: directory.digest } as Record + delete renamed.digest + expect(() => verifySnapshotArchiveDirectory(renamed, expected, now)).toThrow('Invalid snapshot archive directory') + const symbol = { ...directory, [Symbol('extra')]: true } + expect(() => verifySnapshotArchiveDirectory(symbol, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test('a correctly hashed but incomplete final table remains invalid', () => { + const { directory } = fixture() + directory.receipts[12].done = false + directory.receipts[12].rows = 1 + directory.rows = 1 + rehash(directory) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test('a correctly hashed additional table remains invalid', () => { + const { directory } = fixture() + directory.receipts.push({ ...directory.receipts[12], sequence: 13 }) + directory.pages++ + rehash(directory) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test('preserves original binding bytes and verifies every table before out-of-order reads', () => { + const { directory, payloads } = fixture(2) + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + expect(verified.bindingJson).toBe(directory.bindingJson) + expect(verified.manifest.binding.sourceStorage.storageIdentityKey).toBe(sourceStorageIdentityKey) + expect(verified.manifest.binding.user.activeStorage).toBe('historical-primary') + expect(verified.tables.provenTxs).toEqual({ first: 0, pages: 3, rows: 2 }) + expect(verified.tables.syncStates).toEqual({ first: 14, pages: 1, rows: 0 }) + for (const index of [14, 0, 7]) { + const page = { ...directory.receipts[index], bytes: payloads[index] } + const copied = verifySnapshotArchivePage(page, verified.receipts[index]) + expect(copied).toEqual(payloads[index]) + expect(copied).not.toBe(payloads[index]) + } +}) + +test('the binding hash uses exact UTF-8 bytes rather than parse/reserialize order', () => { + const { directory, binding } = fixture() + directory.bindingJson = JSON.stringify( + { + user: binding.user, + sourceSchema: binding.sourceSchema, + sourceStorage: binding.sourceStorage, + snapshotId: binding.snapshotId, + version: binding.version + }, + null, + 1 + ) + rehash(directory) + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + expect(verified.bindingJson).toBe(directory.bindingJson) + const compact = { ...directory, bindingJson: JSON.stringify(JSON.parse(directory.bindingJson)) } + expect(() => verifySnapshotArchiveDirectory(compact, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test('caller mutations cannot replace verified receipt or table positions', () => { + const { directory } = fixture() + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + directory.receipts[0].digest = 'f'.repeat(64) + directory.receipts.reverse() + expect(verified.receipts[0].table).toBe('provenTxs') + expect(verified.receipts[0].digest).not.toBe('f'.repeat(64)) + expect(Object.isFrozen(verified)).toBe(true) + expect(Object.isFrozen(verified.receipts)).toBe(true) + expect(Object.isFrozen(verified.receipts[0])).toBe(true) + expect(Object.isFrozen(verified.tables)).toBe(true) + expect(Object.isFrozen(verified.tables.provenTxs)).toBe(true) +}) + +test.each([ + ['version', 2], + ['encoding', 'other'], + ['archiveId', 'B'.repeat(64)], + ['expiresAt', now], + ['expiresAt', now + 3600001], + ['pages', 12], + ['pages', 4097], + ['pages', 13.5], + ['rows', -1], + ['rows', Number.MAX_SAFE_INTEGER], + ['digest', 'z'.repeat(64)], + ['bindingJson', ''], + ['receipts', null] +])('rejects invalid directory %s=%s', (key, value) => { + const { directory } = fixture() + expect(() => verifySnapshotArchiveDirectory({ ...directory, [key]: value }, expected, now)).toThrow( + 'Invalid snapshot archive directory' + ) +}) + +test.each([NaN, Infinity, -1, 0.5])('rejects invalid verifier clock %s', clock => { + expect(() => verifySnapshotArchiveDirectory(fixture().directory, expected, clock)).toThrow( + 'Invalid snapshot archive directory' + ) +}) + +test.each([ + { identityKey: '03' + '22'.repeat(32) }, + { chain: 'main' as const }, + { sourceStorageIdentityKey: 'replacement' }, + { sourceSchema: 'replacement' }, + { archiveId: 'c'.repeat(64) }, + { digest: 'd'.repeat(64) } +])('rejects changed expected profile/source or resumed binding %p', changed => { + expect(() => verifySnapshotArchiveDirectory(fixture().directory, { ...expected, ...changed }, now)).toThrow( + 'Invalid snapshot archive directory' + ) +}) + +test('a resumed handle can require its already-observed root and schema', () => { + const { directory, binding } = fixture() + expect( + verifySnapshotArchiveDirectory( + directory, + { ...expected, archiveId: directory.archiveId, digest: directory.digest, sourceSchema: binding.sourceSchema }, + now + ).manifest.digest + ).toBe(directory.digest) +}) + +test.each(['version', 'snapshotId', 'sourceSchema', 'sourceStorage', 'user'])( + 'refuses missing binding field %s', + key => { + const { directory, binding } = fixture() + const copy = { ...binding } as Record + delete copy[key] + directory.bindingJson = JSON.stringify(copy) + rehash(directory) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') + } +) + +test('rejects extra internal credentials without reading accessor values', () => { + const { directory } = fixture() + expect(() => verifySnapshotArchiveDirectory({ ...directory, writerToken: 'internal' }, expected, now)).toThrow( + 'Invalid snapshot archive directory' + ) + let reads = 0 + const withAccessor = { ...directory } + Object.defineProperty(withAccessor, 'digest', { + enumerable: true, + get: () => { + reads++ + return directory.digest + } + }) + expect(() => verifySnapshotArchiveDirectory(withAccessor, expected, now)).toThrow( + 'Invalid snapshot archive directory' + ) + expect(reads).toBe(0) + const receipts = [...directory.receipts] + Object.defineProperty(receipts, 0, { + get: () => { + reads++ + return directory.receipts[0] + } + }) + expect(() => verifySnapshotArchiveDirectory({ ...directory, receipts }, expected, now)).toThrow( + 'Invalid snapshot archive directory' + ) + expect(reads).toBe(0) +}) + +test.each(['omitted', 'reordered', 'unfinished', 'wrong-table', 'wrong-sequence', 'wrong-total', 'different-root'])( + 'rejects %s receipt chains', + variant => { + const { directory } = fixture() + if (variant === 'omitted') delete directory.receipts[0] + if (variant === 'reordered') directory.receipts.reverse() + if (variant === 'unfinished') directory.receipts[0].done = false + if (variant === 'wrong-table') directory.receipts[0].table = 'outputs' + if (variant === 'wrong-sequence') directory.receipts[0].sequence = 1 + if (variant === 'wrong-total') directory.rows = 1 + if (variant === 'different-root') directory.receipts[0].digest = 'c'.repeat(64) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') + } +) + +test('the maximum receipt count remains bounded below the transport metadata budget', () => { + const { directory, binding } = fixture(4096 - 13) + const base = new TextEncoder().encode(JSON.stringify(binding)).length + binding.sourceStorage.storageName = '\u0001'.repeat( + Math.floor((65536 - base + binding.sourceStorage.storageName.length) / 6) + ) + binding.sourceStorage.storageName += 'x'.repeat(65536 - new TextEncoder().encode(JSON.stringify(binding)).length) + directory.bindingJson = JSON.stringify(binding) + expect(new TextEncoder().encode(directory.bindingJson)).toHaveLength(65536) + rehash(directory) + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + expect(verified.receipts).toHaveLength(4096) + expect(new TextEncoder().encode(JSON.stringify(directory)).length).toBeLessThan(snapshotArchiveDirectoryBytes) +}) + +test('accepts the exact byte and row limits without changing copied page bytes', () => { + const { directory } = fixture() + const bytes = new Uint8Array(1024 * 1024).fill(7) + directory.receipts[0].digest = hash(bytes) + directory.receipts[0].rows = 1000 + directory.rows = 1000 + rehash(directory) + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + const received = verifySnapshotArchivePage({ ...directory.receipts[0], bytes }, verified.receipts[0]) + expect(received).toHaveLength(1024 * 1024) + expect(hash(received)).toBe(hash(bytes)) + expect(verified.tables.provenTxs.rows).toBe(1000) +}) + +test.each(['metadata', 'bytes', 'digest', 'extra', 'empty', 'oversized'])( + 'rejects %s page substitution against the verified receipt', + variant => { + const { directory, payloads } = fixture() + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + const page: Record = { ...directory.receipts[0], bytes: payloads[0] } + if (variant === 'metadata') page.table = 'outputs' + if (variant === 'bytes') page.bytes = payloads[1] + if (variant === 'digest') page.digest = 'c'.repeat(64) + if (variant === 'extra') page.writerToken = 'internal' + if (variant === 'empty') page.bytes = new Uint8Array() + if (variant === 'oversized') page.bytes = new Uint8Array(1024 * 1024 + 1) + expect(() => verifySnapshotArchivePage(page, verified.receipts[0])).toThrow('Invalid snapshot archive directory') + } +) + +test.each([0, null, { length: 12 }, ''])('refuses a non-text or empty source schema %p', value => { + const { directory, binding } = fixture() + directory.bindingJson = JSON.stringify({ ...binding, sourceSchema: value }) + rehash(directory) + expect(() => verifySnapshotArchiveDirectory(directory, expected, now)).toThrow('Invalid snapshot archive directory') +}) + +test.each(['count', 'array-like', 'done-type', 'empty-continuation'])( + 'refuses correctly hashed invalid receipt structure %s', + variant => { + const { directory } = fixture(1) + if (variant === 'count') directory.receipts.push({ ...directory.receipts[13], sequence: 14 }) + if (variant === 'done-type') Object.assign(directory.receipts[0], { done: 0 }) + if (variant === 'empty-continuation') { + directory.receipts[0].rows = 0 + directory.rows = 0 + } + rehash(directory) + const input = + variant === 'array-like' + ? { ...directory, receipts: { ...directory.receipts, length: directory.pages } } + : directory + expect(() => verifySnapshotArchiveDirectory(input, expected, now)).toThrow('Invalid snapshot archive directory') + } +) + +test.each(['sequence', 'rows', 'done'])('refuses changed page %s even when its payload digest is correct', field => { + const { directory, payloads } = fixture() + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + const page = { ...directory.receipts[0], bytes: payloads[0] } + const changed = field === 'done' ? false : 1 + expect(() => verifySnapshotArchivePage({ ...page, [field]: changed }, verified.receipts[0])).toThrow( + 'Invalid snapshot archive directory' + ) +}) + +test.each(['array', 'array-like', 'empty', 'oversized'])( + 'requires bounded typed page bytes with a matching digest: %s', + variant => { + const bytes = + variant === 'empty' ? new Uint8Array() : new Uint8Array(variant === 'oversized' ? 1024 * 1024 + 1 : 1).fill(9) + const { directory } = fixture() + directory.receipts[0].digest = hash(bytes) + rehash(directory) + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + const input = variant === 'array' ? Array.from(bytes) : variant === 'array-like' ? { 0: 9, length: 1 } : bytes + expect(() => verifySnapshotArchivePage({ ...directory.receipts[0], bytes: input }, verified.receipts[0])).toThrow( + 'Invalid snapshot archive directory' + ) + } +) + +test('accepts one byte with the digest bound into the directory', () => { + const bytes = Uint8Array.of(9) + const { directory } = fixture() + directory.receipts[0].digest = hash(bytes) + rehash(directory) + const verified = verifySnapshotArchiveDirectory(directory, expected, now) + expect(verifySnapshotArchivePage({ ...directory.receipts[0], bytes }, verified.receipts[0])).toEqual(bytes) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.ts new file mode 100644 index 000000000..17e838007 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.ts @@ -0,0 +1,275 @@ +import { syncTransferDigest } from '../../remoting/SyncTransfer' +import type { WalletSnapshotTable } from '../WalletReadSnapshot' +import { + snapshotArchiveLimits, + snapshotArchiveTables, + type SnapshotArchiveBinding, + type SnapshotArchiveManifest, + type SnapshotArchivePage +} from './SnapshotArchive' + +export const snapshotArchiveEncoding = 'wallet-snapshot-rows/1' +export const snapshotArchiveDirectoryBytes = 1024 * 1024 + +export type SnapshotArchiveReceipt = Omit + +/** The exact binding JSON is the hash preimage, not a reserialized object. */ +export interface SnapshotArchiveDirectory { + version: 1 + encoding: typeof snapshotArchiveEncoding + archiveId: string + expiresAt: number + pages: number + rows: number + digest: string + bindingJson: string + receipts: SnapshotArchiveReceipt[] +} + +export interface SnapshotArchiveExpectedSource { + identityKey: string + chain: 'main' | 'test' + sourceStorageIdentityKey: string + archiveId?: string + digest?: string + sourceSchema?: string +} + +export interface SnapshotArchiveTableRange { + first: number + pages: number + rows: number +} + +export interface VerifiedSnapshotArchiveDirectory { + manifest: SnapshotArchiveManifest + bindingJson: string + receipts: ReadonlyArray> + tables: Readonly>> +} + +function invalid(): never { + throw new TypeError('Invalid snapshot archive directory') +} + +function record(value: unknown, fields: string[]): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) invalid() + const keys = Reflect.ownKeys(value) + if (keys.length !== fields.length) invalid() + const result: Record = {} + for (const field of fields) { + const property = Object.getOwnPropertyDescriptor(value, field) + if (property === undefined || !('value' in property) || property.enumerable !== true) invalid() + result[field] = property.value + } + return result +} + +function integer(value: unknown, minimum: number, maximum: number): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) invalid() + return value as number +} + +function text(value: unknown, maximum: number, minimum = 1): string { + if (typeof value !== 'string' || value.length < minimum || value.length > maximum) invalid() + return value +} + +function digest(value: unknown): string { + const result = text(value, 64) + if (!/^[0-9a-f]{64}$/.test(result)) invalid() + return result +} + +function date(value: unknown): Date { + const result = new Date(text(value, 32)) + if (!Number.isFinite(result.getTime()) || result.toISOString() !== value) invalid() + return result +} + +function binding(encoded: string, expected: SnapshotArchiveExpectedSource): SnapshotArchiveBinding { + const bytes = new TextEncoder().encode(encoded) + if (bytes.length > snapshotArchiveLimits.bindingBytes || new TextDecoder().decode(bytes) !== encoded) invalid() + const value = record(JSON.parse(encoded), ['version', 'snapshotId', 'sourceStorage', 'sourceSchema', 'user']) + if (value.version !== 1) invalid() + const storage = record(value.sourceStorage, [ + 'created_at', + 'updated_at', + 'storageIdentityKey', + 'storageName', + 'chain', + 'dbtype', + 'maxOutputScript' + ]) + const user = record(value.user, ['created_at', 'updated_at', 'userId', 'identityKey', 'activeStorage']) + if (expected.sourceSchema !== undefined && value.sourceSchema !== expected.sourceSchema) invalid() + if ( + storage.chain !== expected.chain || + storage.storageIdentityKey !== expected.sourceStorageIdentityKey || + user.identityKey !== expected.identityKey + ) + invalid() + if (storage.dbtype !== 'SQLite' && storage.dbtype !== 'MySQL') invalid() + return { + version: 1, + snapshotId: digest(value.snapshotId), + sourceSchema: text(value.sourceSchema, 256), + sourceStorage: { + created_at: date(storage.created_at), + updated_at: date(storage.updated_at), + storageIdentityKey: text(storage.storageIdentityKey, 130), + storageName: text(storage.storageName, snapshotArchiveLimits.bindingBytes, 0), + chain: expected.chain, + dbtype: storage.dbtype, + maxOutputScript: integer(storage.maxOutputScript, 0, Number.MAX_SAFE_INTEGER) + }, + user: { + created_at: date(user.created_at), + updated_at: date(user.updated_at), + userId: integer(user.userId, 1, Number.MAX_SAFE_INTEGER), + identityKey: text(user.identityKey, 66), + activeStorage: text(user.activeStorage, 130, 0) + } + } +} + +interface ValidatedDirectoryHeader { + archiveId: string + expiresAt: number + pages: number + rows: number + digest: string + bindingJson: string + source: SnapshotArchiveBinding + receipts: unknown[] +} + +function directoryHeader( + input: unknown, + expected: SnapshotArchiveExpectedSource, + now: number +): ValidatedDirectoryHeader { + const value = record(input, [ + 'version', + 'encoding', + 'archiveId', + 'expiresAt', + 'pages', + 'rows', + 'digest', + 'bindingJson', + 'receipts' + ]) + if (value.version !== 1 || value.encoding !== snapshotArchiveEncoding) invalid() + const archiveId = digest(value.archiveId) + if (expected.archiveId !== undefined && archiveId !== expected.archiveId) invalid() + const expiresAt = integer(value.expiresAt, 1, Number.MAX_SAFE_INTEGER) + integer(now, 0, Number.MAX_SAFE_INTEGER) + if (expiresAt <= now || expiresAt - now > snapshotArchiveLimits.lifetimeMs) invalid() + const pages = integer(value.pages, snapshotArchiveTables.length, snapshotArchiveLimits.pages) + const rows = integer(value.rows, 0, snapshotArchiveLimits.pages * snapshotArchiveLimits.rowsPerPage) + const root = digest(value.digest) + if (expected.digest !== undefined && root !== expected.digest) invalid() + const bindingJson = text(value.bindingJson, snapshotArchiveLimits.bindingBytes) + const source = binding(bindingJson, expected) + if (!Array.isArray(value.receipts) || value.receipts.length !== pages) invalid() + return { archiveId, expiresAt, pages, rows, digest: root, bindingJson, source, receipts: value.receipts } +} + +function pageReceipt( + input: unknown[], + index: number, + table: WalletSnapshotTable | undefined +): Readonly { + const slot = Object.getOwnPropertyDescriptor(input, index) + if (slot === undefined || !('value' in slot)) invalid() + const raw = record(slot.value, ['sequence', 'table', 'rows', 'done', 'digest']) + if (table === undefined || raw.sequence !== index || raw.table !== table || typeof raw.done !== 'boolean') invalid() + const rows = integer(raw.rows, 0, snapshotArchiveLimits.rowsPerPage) + if (rows === 0 && !raw.done) invalid() + return Object.freeze({ sequence: index, table, rows, done: raw.done, digest: digest(raw.digest) }) +} + +function receiptDirectory( + header: ValidatedDirectoryHeader +): Pick { + let previous = syncTransferDigest(new TextEncoder().encode(header.bindingJson)) + let tableIndex = 0 + let totalRows = 0 + const receipts: Array> = [] + const tables: Partial>> = {} + let first = 0 + let tableRows = 0 + for (let index = 0; index < header.pages; index++) { + const receipt = pageReceipt(header.receipts, index, snapshotArchiveTables[tableIndex]) + previous = syncTransferDigest( + new TextEncoder().encode( + JSON.stringify([previous, index, receipt.table, receipt.rows, receipt.done, receipt.digest]) + ) + ) + receipts.push(receipt) + totalRows += receipt.rows + tableRows += receipt.rows + if (receipt.done) { + tables[receipt.table] = Object.freeze({ first, pages: index - first + 1, rows: tableRows }) + first = index + 1 + tableRows = 0 + tableIndex++ + } + } + if (tableIndex !== snapshotArchiveTables.length || totalRows !== header.rows || previous !== header.digest) invalid() + return { + receipts: Object.freeze(receipts), + tables: Object.freeze(tables) as VerifiedSnapshotArchiveDirectory['tables'] + } +} + +/** Verify the complete bounded receipt chain before requesting arbitrary tables. */ +export function verifySnapshotArchiveDirectory( + input: unknown, + expected: SnapshotArchiveExpectedSource, + now = Date.now() +): VerifiedSnapshotArchiveDirectory { + const header = directoryHeader(input, expected, now) + const directory = receiptDirectory(header) + // Field and receipt limits bound the reconstructed metadata below one MiB. + // The transport separately caps bytes before parsing the response envelope. + Object.freeze(header.source.sourceStorage) + Object.freeze(header.source.user) + Object.freeze(header.source) + return Object.freeze({ + manifest: Object.freeze({ + version: 1 as const, + archiveId: header.archiveId, + expiresAt: header.expiresAt, + pages: header.pages, + rows: header.rows, + digest: header.digest, + binding: header.source + }), + bindingJson: header.bindingJson, + ...directory + }) +} + +/** Validate bytes against the already-verified directory, not a peer-supplied digest alone. */ +export function verifySnapshotArchivePage(input: unknown, receipt: Readonly): Uint8Array { + const value = record(input, ['sequence', 'table', 'rows', 'done', 'digest', 'bytes']) + if ( + value.sequence !== receipt.sequence || + value.table !== receipt.table || + value.rows !== receipt.rows || + value.done !== receipt.done || + value.digest !== receipt.digest + ) + invalid() + if ( + !(value.bytes instanceof Uint8Array) || + value.bytes.length < 1 || + value.bytes.length > snapshotArchiveLimits.pageBytes + ) + invalid() + const bytes = new Uint8Array(value.bytes) + if (syncTransferDigest(bytes) !== receipt.digest) invalid() + return bytes +} diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveDirectoryFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveDirectoryFixtures.ts new file mode 100644 index 000000000..cc5f2bc4f --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveDirectoryFixtures.ts @@ -0,0 +1,85 @@ +import { createHash } from 'node:crypto' +import type { WalletSnapshotTable } from '../../src/storage/snapshot/WalletReadSnapshot' +import type { SnapshotArchiveDirectory } from '../../src/storage/snapshot/archive/SnapshotArchiveDirectory' + +export const now = Date.UTC(2026, 8, 30) +const identityKey = '02' + '11'.repeat(32) +export const sourceStorageIdentityKey = 'original-storage' +export const expected = { identityKey, chain: 'test' as const, sourceStorageIdentityKey } +export const tables: WalletSnapshotTable[] = [ + 'provenTxs', + 'provenTxReqs', + 'outputBaskets', + 'transactions', + 'commissions', + 'outputs', + 'outputTags', + 'outputTagMaps', + 'txLabels', + 'txLabelMaps', + 'certificates', + 'certificateFields', + 'syncStates' +] +export const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex') + +export function fixture(extraPages = 0) { + const binding = { + version: 1, + snapshotId: 'a'.repeat(64), + sourceStorage: { + created_at: new Date(now - 1000).toISOString(), + updated_at: new Date(now).toISOString(), + storageIdentityKey: sourceStorageIdentityKey, + storageName: 'Original source', + chain: 'test', + dbtype: 'SQLite', + maxOutputScript: 100000 + }, + sourceSchema: '2026-09-30-002 add snapshot archive staging', + user: { + created_at: new Date(now - 1000).toISOString(), + updated_at: new Date(now).toISOString(), + userId: 7, + identityKey, + activeStorage: 'historical-primary' + } + } + const bindingJson = JSON.stringify(binding) + const directory: SnapshotArchiveDirectory = { + version: 1, + encoding: 'wallet-snapshot-rows/1', + archiveId: 'b'.repeat(64), + expiresAt: now + 1000, + pages: 13 + extraPages, + rows: extraPages, + digest: '', + bindingJson, + receipts: [] + } + const payloads: Uint8Array[] = [] + for (const table of tables) { + const count = table === 'provenTxs' ? extraPages + 1 : 1 + for (let page = 0; page < count; page++) { + const bytes = new TextEncoder().encode(`${table}:${page}`) + const done = page === count - 1 + payloads.push(bytes) + directory.receipts.push({ + sequence: directory.receipts.length, + table, + rows: done ? 0 : 1, + done, + digest: hash(bytes) + }) + } + } + rehash(directory) + return { directory, payloads, binding } +} + +export function rehash(directory: SnapshotArchiveDirectory): void { + let previous = hash(directory.bindingJson) + for (const page of directory.receipts) + previous = hash(JSON.stringify([previous, page.sequence, page.table, page.rows, page.done, page.digest])) + directory.digest = previous +} diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 49ef9d04c..50be5c496 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 38) + assert.equal(result.summary.propertySuites, 39) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 37) - assert.equal(result.summary.mutationTargets, 38) + assert.equal(result.summary.mutationTargets, 39) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 49b96fed7..f4208f72a 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -116,6 +116,16 @@ authenticated client/server integration, larger-wallet resource policy and measured physical storage costs remain open. The migration and local controller do not expose or advertise a remote snapshot API. +The internal directory component now authenticates every page receipt under the +complete root chain and builds a fixed thirteen-table index. SQL reads select +only bounded receipt metadata, retain the original binding bytes and recheck +profile/expiry after I/O. A separate verifier binds expected source/profile/root, +rejects malformed metadata and checks each detached payload against its verified +receipt. Independent SHA-256 fixtures, 300 generated directory cases, maximum +size boundaries and cross-connection SQL capture reads exercise this contract. +This advances S3 table positioning without adding a remote endpoint, negotiating +capabilities or establishing portable semantic/proof validation. + These checkpoints advance parts of S1/S2/P1/S4. They do not complete primary reconciliation, indexed identity/update predicates and commit-order high-water positions, authenticated remote views, durable source views, streaming or staged From 660fe768c3ab074d2090271f110bd3427d750819 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 13:10:19 -0700 Subject: [PATCH 052/127] feat(wallet): bind durable snapshot requests to owned captures --- docs/guides/wallet-sync-reliability.md | 42 +- docs/reference/package-api-migrations.md | 76 ++-- governance/mutation-testing/policy.json | 10 + governance/mutation-testing/targets.mjs | 59 ++- governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 13 + packages/wallet/wallet-toolbox/README.md | 8 + packages/wallet/wallet-toolbox/package.json | 2 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 122 ++++-- .../src/storage/schema/KnexMigrations.ts | 12 + .../schema/snapshotArchiveRequestMigration.ts | 31 ++ .../ConcurrentSnapshotArchiveSource.test.ts | 157 +++++++ .../KnexSnapshotArchiveCapture.test.ts | 8 +- .../KnexSnapshotArchiveRequestStore.test.ts | 407 ++++++++++++++++++ .../KnexSnapshotArchiveRequestStore.ts | 228 ++++++++++ .../KnexSnapshotArchiveService.test.ts | 336 +++++++++++++++ .../archive/KnexSnapshotArchiveService.ts | 165 +++++++ .../archive/KnexSnapshotArchiveSource.ts | 8 + .../archive/KnexSnapshotArchiveStore.test.ts | 2 +- .../archive/KnexSnapshotArchiveStore.ts | 20 +- .../archive/SnapshotArchiveRequest.test.ts | 125 ++++++ .../archive/SnapshotArchiveRequest.ts | 83 ++++ .../SnapshotArchiveService.property.test.ts | 122 ++++++ .../snapshot/archive/SnapshotArchiveSql.ts | 28 ++ .../archive/captureKnexSnapshotArchive.ts | 119 +---- .../archive/captureSnapshotArchiveSource.ts | 118 +++++ .../test/storage/snapshotArchiveMysql.cjs | 110 ++++- scripts/test-governance.test.mjs | 4 +- specs/wallet/sync-portability-program.md | 12 + 29 files changed, 2211 insertions(+), 220 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveRequestMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveSql.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureSnapshotArchiveSource.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 6773eae1e..a069a02d6 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -390,8 +390,46 @@ binding private and give callers fresh metadata copies. The maximum accepted directory fits below a one-MiB metadata budget. Transport integration must separately limit incoming envelope bytes before parsing. These checks establish inclusion under the received root, not independent trust in the -source's data or proof semantics. The wire adapter, durable creation receipts, -capability negotiation and actual authenticated HTTP lifecycle remain incomplete. +source's data or proof semantics. The wire adapter, capability negotiation and +actual authenticated HTTP lifecycle remain incomplete. + +The internal creation-request store adds the separate +`2026-09-30-003 add snapshot archive requests` migration. A request ID hashes its +version, nonce, immutable absolute deadline and byte reservation. Admission checks +the database clock; the same expired request cannot reopen after its receipt has +been collected. A future client must obtain fresh authenticated server time +before selecting that deadline. This is not yet an exposed wire capability. + +Claiming a request reserves the existing shared handle/byte capacity before a +source pool opens. Archive assignment and the handoff from that pending charge +commit together; sealing and ready-receipt publication also share a transaction. +Lost acknowledgements recover the same ready archive. A process that loses an +incomplete source view cannot replace it under the same request. Terminal status +is separate from cleanup ownership: capacity stays occupied until pending charges +or staged pages are released exactly once. Retained receipts are limited to four +per profile and 64 globally, including terminal history; only expired, released +receipts can be collected. Close outstanding requests before removing this schema. + +The request lifecycle is tested through actual SQLite transactions, independent +connections, interrupted assignment/publication and generated retry/cancel +schedules. Separate synchronous SQLite pools in one event loop can return +`SQLITE_BUSY`; tests drain both operations, check that exact refusal and retry +without double release. This does not qualify foreground latency or transparent +contention recovery. + +The internal capture service now uses the provider's existing single owned reader +slot, shared with local sync. It tracks admission synchronously before any await, +claims SQL capacity before pool acquisition, and binds same-request retries to the +same completion or durable receipt. Capture and pool cleanup precede ready +publication. Explicit cancellation and service shutdown wait for owned cleanup; +shutdown fences new captures while completed archives remain available to a +replacement service. Failed physical cleanup fences the controller and retains +its reservation, including failure during source opening before a view is returned. +The source view lasts at most five minutes or the request's remaining lifetime; +the ready archive retains its original fixed deadline. This bounds process-local +reader ownership, not distributed physical pools through arbitrary process loss +or unbounded driver cleanup. Authentication, capability negotiation and bounded +HTTP/client integration remain required before enabling remote snapshots. The initial policy allows at most eight handles and 128 MiB of logical reserved storage globally, one handle and 32 MiB per profile, 1 MiB per page, 1,000 rows per diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index ed49b230d..d52ed675d 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. These components do not enable an authenticated service; HTTP/client integration, capability negotiation and contention performance qualification remain incomplete. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. These components do not enable an authenticated service; HTTP/client integration, capability negotiation and contention performance qualification remain incomplete. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index f383f0fa8..0baea5406 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -117,6 +117,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-snapshot-remote-service", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts", + "risk": "critical", + "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation and cleanup lifecycle", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "overlay-linkage", "manifest": "packages/overlays/topics/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 0724c0fce..89b5c9543 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -150,6 +150,8 @@ function snapshotSyncMutationTargets(repositoryRoot) { [ '/src/storage/snapshot/SnapshotSync*.test.ts', '/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts', + '/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts', + '/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts', '/src/storage/schema/snapshotSyncMigration.test.ts', '/src/storage/methods/validateSyncProof.test.ts', '/src/storage/sync/syncFailure.test.ts', @@ -426,19 +428,33 @@ export function buildMutationTargets(repositoryRoot) { mutate: [ 'src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts', 'src/storage/snapshot/archive/SnapshotArchive.ts', + 'src/storage/snapshot/archive/SnapshotArchiveSql.ts', 'src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts', 'src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts', 'src/storage/snapshot/archive/captureKnexSnapshotArchive.ts', + 'src/storage/snapshot/archive/captureSnapshotArchiveSource.ts', 'src/storage/schema/snapshotArchiveMigration.ts' ], - ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/archive/*.test.ts'], { - config: { - moduleNameMapper: { - '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), - '^(\\.{1,2}/.*)\\.js$': '$1' + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts', + '/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts', + '/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts', + '/src/storage/snapshot/archive/SnapshotArchiveDirectory.test.ts', + '/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts', + '/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts', + '/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } } } - }) + ) }, 'wallet-snapshot-remote-directory': { packageDirectory: 'packages/wallet/wallet-toolbox', @@ -459,6 +475,37 @@ export function buildMutationTargets(repositoryRoot) { } ) }, + 'wallet-snapshot-remote-service': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts', + mutate: [ + 'src/storage/snapshot/archive/SnapshotArchiveRequest.ts', + 'src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts', + 'src/storage/snapshot/archive/KnexSnapshotArchiveService.ts', + 'src/storage/snapshot/archive/SnapshotArchiveSql.ts', + 'src/storage/schema/snapshotArchiveRequestMigration.ts' + ], + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/snapshot/archive/SnapshotArchiveRequest.test.ts', + '/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts', + '/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts', + '/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts', + '/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + }, ...snapshotSyncMutationTargets(repositoryRoot), 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 3acf73ee7..60c2f6ef7 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,8 +217,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. These components do not enable an authenticated service; HTTP/client integration, capability negotiation and contention performance qualification remain incomplete." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index ec3a3ff42..d9b7447c1 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -535,6 +535,19 @@ "Changed payload, receipt or previously pinned root bytes reject.", "Caller mutation does not alter verified receipt positions or metadata." ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation and cleanup lifecycle", + "target": "Generated SQL claim, retry, capture publication and terminal cleanup schedules", + "invariants": [ + "A retry returns the original request and immutable ready archive without a second admission or writer token.", + "Pending requests reserve the same shared capacity used by local archives before source acquisition.", + "Archive assignment and ready receipt publication commit atomically.", + "Terminal requests retain bounded receipts and release physical capacity exactly once." + ] } ], "exclusions": [ diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 0eb7e017b..d61e764c2 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -56,6 +56,14 @@ positions to the archive root before arbitrary-table reads; each payload is checked against that verified directory. The original binding JSON bytes are preserved as the hash preimage. This verifies transport integrity, not the source's honesty or portable transaction/proof semantics. +An additional internal SQL request table binds retries to an immutable deadline +and archive, reserves capacity before source acquisition, and retains bounded +terminal receipts through cleanup. Its internal capture controller shares the +provider's single owned reader slot, publishes readiness after physical cleanup, +and drains cancellation/shutdown. Completed archives survive controller replacement; +failed cleanup fences admission and retains its reservation. Authenticated HTTP, +capability negotiation and bounded client integration remain incomplete; this does +not enable remote snapshots. The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index bd13c2969..038ca5145 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index b40f3f582..2cc3d4993 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -3,6 +3,11 @@ import type { SnapshotSyncStorage } from './snapshot/SnapshotSync' import { KnexSnapshotSyncDestination } from './snapshot/KnexSnapshotSyncDestination' import type { WalletReadSnapshot, WalletReadSnapshotOptions } from './snapshot/WalletReadSnapshot' import { openKnexWalletReadSnapshot } from './snapshot/KnexWalletReadSnapshot' +import { + openKnexSnapshotArchiveSource, + SnapshotArchiveSourceCleanupError, + type SnapshotArchiveSource +} from './snapshot/archive/KnexSnapshotArchiveSource' import { retainReadSnapshot, type RetainedReadSnapshot, @@ -302,17 +307,42 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide const destination = new KnexSnapshotSyncDestination(this, chunk => this.prepareSyncProofs(chunk)) return { supportsDestination: () => destination.supportsDestination(), - openSource: (identityKey, options) => this.openConcurrentSyncSource(identityKey, options), + openSource: (identityKey, options) => + this.openConcurrentSyncSource(identityKey, options ?? {}, (reader, identity, settings) => + reader.openWalletReadSnapshot(identity, settings) + ), begin: (source, activeStorage) => destination.begin(source, activeStorage), checkpoint: (identityKey, sourceIdentity) => destination.checkpoint(identityKey, sourceIdentity), prepare: (checkpoint, page) => destination.prepare(checkpoint, page) } } - private async openConcurrentSyncSource( + /** Local capability probe; no reader pool is constructed or RPC advertised. */ + async supportsSnapshotArchiveSource(): Promise { + if (this.retainedReadSnapshotsStopped) return false + const config = await this.concurrentSnapshotReaderConfig() + return !this.retainedReadSnapshotsStopped && config !== undefined + } + + /** Shares local sync's single owned reader slot and awaited physical cleanup. */ + async openSnapshotArchiveSource( identityKey: string, options: WalletReadSnapshotOptions = {} - ): Promise { + ): Promise { + try { + return await this.openConcurrentSyncSource(identityKey, options, openKnexSnapshotArchiveSource) + } catch (error) { + if (this.retainedReadSnapshotsStopped && this.snapshotSyncSource !== undefined) + throw new SnapshotArchiveSourceCleanupError(error) + throw error + } + } + + private async openConcurrentSyncSource( + identityKey: string, + options: WalletReadSnapshotOptions, + openSource: (reader: StorageKnex, identityKey: string, options: WalletReadSnapshotOptions) => Promise + ): Promise { if (this.retainedReadSnapshotsStopped) throw new WERR_INVALID_OPERATION('Snapshot sync is unavailable after destruction begins') if (this.snapshotSyncBusy) @@ -323,10 +353,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } const deadline = { expiresAt: Date.now() + lifetimeMs, startedAt: performance.now(), lifetimeMs } this.snapshotSyncBusy = true - const opening: Promise = this.createConcurrentSyncSource( + const opening: Promise = this.createConcurrentSyncSource( identityKey, { ...options }, - deadline + deadline, + openSource ) .then( view => { @@ -345,39 +376,15 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return await opening } - private async createConcurrentSyncSource( + private async createConcurrentSyncSource( identityKey: string, options: WalletReadSnapshotOptions, - deadline: { expiresAt: number; startedAt: number; lifetimeMs: number } - ): Promise { + deadline: { expiresAt: number; startedAt: number; lifetimeMs: number }, + openSource: (reader: StorageKnex, identityKey: string, options: WalletReadSnapshotOptions) => Promise + ): Promise { if (options.signal?.aborted === true) throw new WERR_INVALID_OPERATION('Snapshot sync source was cancelled') - const config = this.knex.client.config as Knex.Config - const connection = config.connection - // A function or external pool cannot promise an independent connection. - // In-memory SQLite cannot share a coherent WAL view with a separate pool. - if ( - connection === null || - typeof connection !== 'object' || - ('connectionPool' in config && config.connectionPool != null) - ) - return undefined - if (this.databaseSystem() === 'sqlite') { - const filename = (connection as Knex.Sqlite3ConnectionConfig).filename - if ( - typeof filename !== 'string' || - filename.length === 0 || - filename === ':memory:' || - filename.startsWith('file:') - ) - return undefined - const modes: Array<{ journal_mode: string }> = await this.knex.raw('PRAGMA journal_mode') - if (modes[0]?.journal_mode.toLowerCase() !== 'wal') return undefined - } else if ( - this.databaseSystem() !== 'mysql' || - !('database' in connection) || - typeof connection.database !== 'string' - ) - return undefined + const config = await this.concurrentSnapshotReaderConfig() + if (config === undefined) return undefined if (this.retainedReadSnapshotsStopped) throw new WERR_INVALID_OPERATION('Snapshot sync is unavailable after destruction begins') const remaining = Math.floor( @@ -388,16 +395,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide ...StorageProvider.createStorageBaseOptions(this.chain), snapshotSync: false, telemetry: this.snapshotSyncTelemetry, - knex: createKnex({ - ...config, // Knex deliberately makes passwords non-enumerable. Preserve descriptors - // in memory instead of dropping credentials or making them log-visible. - connection: Object.create(Object.getPrototypeOf(connection), Object.getOwnPropertyDescriptors(connection)), - pool: { ...config.pool, min: 0, max: 1 } - }) + knex: createKnex(config) }) this.snapshotSyncSource = reader try { - const view = await reader.openWalletReadSnapshot(identityKey, { ...options, lifetimeMs: remaining }) + const view = await openSource(reader, identityKey, { ...options, lifetimeMs: remaining }) if (this.retainedReadSnapshotsStopped) { await view.close() throw new WERR_INVALID_OPERATION('Snapshot sync is unavailable after destruction begins') @@ -421,6 +423,42 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } } + private async concurrentSnapshotReaderConfig(): Promise { + const config = this.knex.client.config as Knex.Config + const connection = config.connection + // A function or external pool cannot promise an independent connection. + // In-memory SQLite cannot share a coherent WAL view with a separate pool. + if ( + connection === null || + typeof connection !== 'object' || + ('connectionPool' in config && config.connectionPool != null) + ) + return undefined + if (this.databaseSystem() === 'sqlite') { + const filename = (connection as Knex.Sqlite3ConnectionConfig).filename + if ( + typeof filename !== 'string' || + filename.length === 0 || + filename === ':memory:' || + filename.startsWith('file:') + ) + return undefined + const modes: Array<{ journal_mode: string }> = await this.knex.raw('PRAGMA journal_mode') + if (modes[0]?.journal_mode.toLowerCase() !== 'wal') return undefined + } else if ( + this.databaseSystem() !== 'mysql' || + !('database' in connection) || + typeof connection.database !== 'string' + ) + return undefined + return { + ...config, + // Preserve hidden credentials and accessors without making them log-visible. + connection: Object.create(Object.getPrototypeOf(connection), Object.getOwnPropertyDescriptors(connection)), + pool: { ...config.pool, min: 0, max: 1 } + } + } + private async releaseConcurrentSource(reader: StorageKnex): Promise { try { await reader.destroy() diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index d9b8ad711..e5a3e3705 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,8 @@ +import { + addSnapshotArchiveRequestTable, + removeSnapshotArchiveRequestTable, + SNAPSHOT_ARCHIVE_REQUEST_MIGRATION +} from './snapshotArchiveRequestMigration' import { addSnapshotArchiveTables, removeSnapshotArchiveTables, @@ -17,6 +22,7 @@ import { LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS } from '../methods/managedChangePolicy' +export { SNAPSHOT_ARCHIVE_REQUEST_MIGRATION } from './snapshotArchiveRequestMigration' export { SNAPSHOT_ARCHIVE_MIGRATION } from './snapshotArchiveMigration' export { SNAPSHOT_SYNC_MIGRATION } from './snapshotSyncMigration' @@ -102,6 +108,12 @@ export class KnexMigrations implements MigrationSource { } } + migrations[SNAPSHOT_ARCHIVE_REQUEST_MIGRATION] = { + config: { transaction: true }, + up: addSnapshotArchiveRequestTable, + down: removeSnapshotArchiveRequestTable + } + migrations[SNAPSHOT_ARCHIVE_MIGRATION] = { config: { transaction: true }, up: addSnapshotArchiveTables, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveRequestMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveRequestMigration.ts new file mode 100644 index 000000000..767b51bce --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveRequestMigration.ts @@ -0,0 +1,31 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' + +export const SNAPSHOT_ARCHIVE_REQUEST_MIGRATION = '2026-09-30-003 add snapshot archive requests' + +export async function addSnapshotArchiveRequestTable(knex: Knex): Promise { + if (!(await knex.schema.hasTable('snapshot_archive_requests'))) { + await knex.schema.createTable('snapshot_archive_requests', table => { + table.string('identityKey', 130).notNullable() + table.string('requestId', 64).notNullable() + table.string('claimToken', 64).notNullable() + table.string('state', 16).notNullable() + table.string('requestJson', 512).notNullable() + table.bigInteger('expiresAt').notNullable().index() + table.bigInteger('reservedBytes').notNullable() + table.string('archiveId', 64).nullable() + table.boolean('released').notNullable() + table.primary(['identityKey', 'requestId']) + }) + } +} + +export async function removeSnapshotArchiveRequestTable(knex: Knex): Promise { + if (await knex.schema.hasTable('snapshot_archive_requests')) { + const active = await knex('snapshot_archive_requests').where({ released: false }).first('requestId') + if (active !== undefined) { + throw new WERR_INVALID_OPERATION('Close snapshot archive requests before removing their schema') + } + } + await knex.schema.dropTableIfExists('snapshot_archive_requests') +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts new file mode 100644 index 000000000..c9b7a46b8 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -0,0 +1,157 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { seedArchiveClosure } from '../../../test/utils/snapshotArchiveFixtures' +import { KnexSnapshotArchiveStore } from './archive/KnexSnapshotArchiveStore' +import { KnexSnapshotArchiveRequestStore } from './archive/KnexSnapshotArchiveRequestStore' +import { snapshotArchiveRequestId } from './archive/SnapshotArchiveRequest' +import { captureSnapshotArchiveSource } from './archive/captureSnapshotArchiveSource' +import { verifySnapshotArchiveDirectory } from './archive/SnapshotArchiveDirectory' + +const identity = '02' + '11'.repeat(32) +const stores: StorageKnex[] = [] +const directories: string[] = [] +function gate() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'owned-archive-source-')) + directories.push(directory) + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + }) + stores.push(storage) + await storage.knex.raw('PRAGMA journal_mode = WAL') + await storage.migrate('original source', 'original-source') + await storage.makeAvailable() + const { user } = await storage.findOrInsertUser(identity) + const { user: other } = await storage.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(storage, user.userId, other.userId) + return storage +} +afterEach(async () => { + jest.restoreAllMocks() + await Promise.all(stores.splice(0).map(store => store.destroy())) + await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +test('a ready request waits for owned reader destruction while foreground storage remains usable', async () => { + const storage = await fixture() + expect(await storage.supportsSnapshotArchiveSource()).toBe(true) + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() + const requests = new KnexSnapshotArchiveRequestStore(storage.knex) + const archives = new KnexSnapshotArchiveStore(storage.knex) + const fields = { version: 1 as const, nonce: 'b'.repeat(64), notAfter: Date.now() + 300000, maxBytes: 32768 } + const input = { ...fields, requestId: snapshotArchiveRequestId(fields) } + const admitted = await requests.claim(identity, input) + const source = (await storage.openSnapshotArchiveSource(identity))! + expect(source.user.identityKey).toBe(identity) + expect(source.sourceStorage.storageIdentityKey).toBe('original-source') + expect(source.sourceSchema).toBe('2026-09-30-003 add snapshot archive requests') + const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex + expect(reader.knex).not.toBe(storage.knex) + expect(reader.knex.client.config.pool).toMatchObject({ min: 0, max: 1 }) + const entered = gate() + const finish = gate() + const destroy = reader.destroy.bind(reader) + const intercept = jest.spyOn(reader, 'destroy').mockImplementation(async () => { + entered.resolve() + await finish.promise + await destroy() + }) + let settled = false + const pending = captureSnapshotArchiveSource( + source, + { + begin: binding => requests.begin(admitted.owner!, binding), + append: (writer, page) => archives.append(writer, page), + seal: writer => requests.seal(admitted.owner!, writer), + close: () => requests.close(identity, input.requestId, 'failed') + }, + identity, + 'test' + ).finally(() => { + settled = true + }) + void pending.catch(() => undefined) + try { + await Promise.race([ + entered.promise, + pending.then(() => { + throw new Error('Capture completed before physical close') + }) + ]) + expect(settled).toBe(false) + expect((await requests.status(identity, input.requestId)).state).toBe('building') + expect((await storage.knex('snapshot_archives').first()).state).toBe('building') + await expect(storage.getSnapshotSync()!.openSource(identity)).rejects.toThrow('opening or active') + await storage.knex('tx_labels').where({ txLabelId: 1 }).update({ label: 'foreground write after capture' }) + finish.resolve() + const manifest = await pending + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() + expect((await requests.status(identity, input.requestId)).state).toBe('ready') + const verified = verifySnapshotArchiveDirectory(await archives.directory(identity, manifest.archiveId), { + identityKey: identity, + chain: 'test', + sourceStorageIdentityKey: 'original-source', + digest: manifest.digest + }) + expect(verified.manifest).toEqual(manifest) + const next = (await storage.getSnapshotSync()!.openSource(identity))! + await next.close() + await requests.close(identity, input.requestId) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) + } finally { + finish.resolve() + await pending.catch(() => undefined) + intercept.mockRestore() + } +}) + +test('provider destruction closes its archive reader and fences further admission', async () => { + const storage = await fixture() + const source = (await storage.openSnapshotArchiveSource(identity))! + await storage.destroy() + await source.closed + expect(source.isOpen).toBe(false) + expect(await storage.supportsSnapshotArchiveSource()).toBe(false) + await expect(storage.openSnapshotArchiveSource(identity)).rejects.toThrow('destruction begins') +}) + +test('cancellation after source acquisition closes the owned pool before any archive is reserved', async () => { + const storage = await fixture() + const source = (await storage.openSnapshotArchiveSource(identity))! + const controller = new AbortController() + controller.abort() + await expect( + captureSnapshotArchiveSource(source, new KnexSnapshotArchiveStore(storage.knex), identity, 'test', { + signal: controller.signal + }) + ).rejects.toThrow('cancelled') + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() + expect(await storage.knex('snapshot_archives')).toHaveLength(0) +}) + +test('unsupported in-memory SQLite does not construct a dedicated archive reader', async () => { + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + }) + stores.push(storage) + expect(await storage.supportsSnapshotArchiveSource()).toBe(false) + expect(await storage.openSnapshotArchiveSource(identity)).toBeUndefined() + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index 05af1dd12..fe1c543aa 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' -import { SNAPSHOT_ARCHIVE_MIGRATION } from '../../schema/KnexMigrations' +import { SNAPSHOT_ARCHIVE_REQUEST_MIGRATION } from '../../schema/KnexMigrations' import { decodeSyncTransfer } from '../../remoting/SyncTransfer' import * as Transfer from '../../remoting/SyncTransfer' import * as ArchiveSource from './KnexSnapshotArchiveSource' @@ -72,7 +72,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { onProgress: p => progress.push(p) }) - expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_ARCHIVE_MIGRATION) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_ARCHIVE_REQUEST_MIGRATION) expect(manifest.binding.sourceStorage.storageName).toBe('original source') expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) @@ -84,7 +84,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof sourceStorageIdentityKey: 'original-source', archiveId: manifest.archiveId, digest: manifest.digest, - sourceSchema: SNAPSHOT_ARCHIVE_MIGRATION + sourceSchema: SNAPSHOT_ARCHIVE_REQUEST_MIGRATION }) expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} @@ -153,7 +153,7 @@ test('source schema, primary history and closure stay pinned while an independen await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) - expect(source.sourceSchema).toBe(SNAPSHOT_ARCHIVE_MIGRATION) + expect(source.sourceSchema).toBe(SNAPSHOT_ARCHIVE_REQUEST_MIGRATION) expect(source.user.activeStorage).toBe(originalPrimary) await expect(source.validateClosure()).resolves.toBeUndefined() expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts new file mode 100644 index 000000000..9f70cb25e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts @@ -0,0 +1,407 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex, type Knex } from 'knex' +import { addSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' +import { + addSnapshotArchiveRequestTable, + removeSnapshotArchiveRequestTable +} from '../../schema/snapshotArchiveRequestMigration' +import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' +import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' +import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import * as ArchiveSql from './SnapshotArchiveSql' +import { snapshotArchiveTables, type SnapshotArchiveBinding, type SnapshotArchiveWriter } from './SnapshotArchive' + +const identity = '02' + '11'.repeat(32) +const other = '03' + '22'.repeat(32) +const date = new Date('2026-01-01T00:00:00.000Z') +const binding: SnapshotArchiveBinding = { + version: 1, + snapshotId: 'a'.repeat(64), + sourceSchema: 'request-fixture-v1', + sourceStorage: { + created_at: date, + updated_at: date, + storageIdentityKey: 'source', + storageName: 'source', + chain: 'test', + dbtype: 'SQLite', + maxOutputScript: 1024 + }, + user: { created_at: date, updated_at: date, userId: 1, identityKey: identity, activeStorage: 'source' } +} +const databases: Knex[] = [] +const directories: string[] = [] + +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-request-')) + directories.push(directory) + const open = () => { + const db = knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'requests.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 5000 + }) + databases.push(db) + return db + } + const db = open() + await db.raw('PRAGMA journal_mode = WAL') + await addSnapshotArchiveTables(db) + await addSnapshotArchiveRequestTable(db) + const peer = open() + return { + db, + peer, + requests: new KnexSnapshotArchiveRequestStore(db), + second: new KnexSnapshotArchiveRequestStore(peer), + archives: new KnexSnapshotArchiveStore(db) + } +} + +function request(nonce = 'b'.repeat(64), maxBytes = 32768) { + const value = { version: 1 as const, nonce, notAfter: Date.now() + 300000, maxBytes } + return { ...value, requestId: snapshotArchiveRequestId(value) } +} + +async function append(archives: KnexSnapshotArchiveStore, writer: SnapshotArchiveWriter) { + for (const [sequence, table] of snapshotArchiveTables.entries()) { + await archives.append(writer, { sequence, table, rows: 0, done: true, bytes: Uint8Array.of(0) }) + } +} + +afterEach(async () => { + jest.restoreAllMocks() + await Promise.all(databases.splice(0).map(db => db.destroy())) + await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +test('lost creation acknowledgements recover the same sealed archive on an independent server', async () => { + const { db, requests, second, archives } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + expect(admitted.receipt).toEqual({ + version: 1, + requestId: input.requestId, + expiresAt: input.notAfter, + state: 'building' + }) + expect(admitted.owner).toBeDefined() + expect(await second.claim(identity, input)).toEqual({ receipt: admitted.receipt }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: input.maxBytes }) + const writer = await requests.begin(admitted.owner!, binding) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: input.maxBytes }) + await append(archives, writer) + const manifest = await requests.seal(admitted.owner!, writer) + expect(manifest.expiresAt).toBe(input.notAfter) + const recovered = await second.claim(identity, input) + expect(recovered).toEqual({ + receipt: { ...admitted.receipt, state: 'ready', archiveId: writer.archiveId, digest: manifest.digest } + }) + expect(await second.status(identity, input.requestId)).toEqual(recovered.receipt) + expect(JSON.stringify(recovered)).not.toContain(admitted.owner!.claimToken) + expect(JSON.stringify(recovered)).not.toContain(writer.writerToken) + await second.close(identity, input.requestId) + await second.close(identity, input.requestId) + expect((await requests.claim(identity, input)).receipt.state).toBe('closed') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + await expect(second.status(other, input.requestId)).rejects.toThrow('unavailable') +}) + +test('a pending claim reserves capacity before any source or archive exists and cancellation releases once', async () => { + const { db, peer, requests, second, archives } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + expect(await db('snapshot_archives')).toHaveLength(0) + await expect(second.claim(identity, request('c'.repeat(64)))).rejects.toThrow('occupied') + // Independent synchronous SQLite pools may report SQLITE_BUSY rather than + // yield to another transaction in this event loop. Drain both requests and + // retry the refused one; neither outcome may double-release its reservation. + await db.raw('PRAGMA busy_timeout = 0') + await peer.raw('PRAGMA busy_timeout = 0') + const outcomes = await Promise.allSettled([ + requests.close(identity, input.requestId), + second.close(identity, input.requestId) + ]) + expect(outcomes.some(result => result.status === 'fulfilled')).toBe(true) + for (const result of outcomes) { + if (result.status === 'rejected') expect(result.reason).toMatchObject({ code: 'SQLITE_BUSY' }) + } + await requests.close(identity, input.requestId) + await second.close(identity, input.requestId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await expect(requests.begin(admitted.owner!, binding)).rejects.toThrow('unavailable') + const local = await archives.begin(binding) + await archives.close(identity, local.archiveId) +}) + +test('failed archive assignment rolls back its capacity handoff and remains safely cancellable', async () => { + const { db, requests } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + await db.raw( + "CREATE TRIGGER fail_request_assignment BEFORE UPDATE ON snapshot_archive_requests WHEN NEW.archiveId IS NOT NULL BEGIN SELECT RAISE(ABORT, 'synthetic assignment interruption'); END" + ) + await expect(requests.begin(admitted.owner!, binding)).rejects.toThrow('synthetic assignment interruption') + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: input.maxBytes }) + expect((await requests.status(identity, input.requestId)).state).toBe('building') + await db.raw('DROP TRIGGER fail_request_assignment') + await requests.close(identity, input.requestId, 'failed') + expect((await requests.status(identity, input.requestId)).state).toBe('failed') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('a failed ready-receipt commit cannot publish the archive separately', async () => { + const { db, requests, archives } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + const writer = await requests.begin(admitted.owner!, binding) + await append(archives, writer) + await db.raw( + "CREATE TRIGGER fail_request_ready BEFORE UPDATE ON snapshot_archive_requests WHEN NEW.state = 'ready' BEGIN SELECT RAISE(ABORT, 'synthetic ready interruption'); END" + ) + await expect(requests.seal(admitted.owner!, writer)).rejects.toThrow('synthetic ready interruption') + expect((await requests.status(identity, input.requestId)).state).toBe('building') + await expect(archives.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') + await db.raw('DROP TRIGGER fail_request_ready') + const manifest = await requests.seal(admitted.owner!, writer) + expect((await requests.status(identity, input.requestId)).digest).toBe(manifest.digest) +}) + +test('pending claims count against ordinary staging capacity and expired claims are reaped', async () => { + const { db, requests, archives } = await fixture() + for (let n = 0; n < 8; n++) + await requests.claim('02' + n.toString(16).padStart(64, '0'), request(n.toString(16).padStart(64, '0'))) + await expect(archives.begin(binding, { maxBytes: 32768 })).rejects.toThrow('occupied') + await expect(requests.claim(identity, request())).rejects.toThrow('occupied') + await db('snapshot_archive_requests').update({ expiresAt: 0 }) + await requests.reap() + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('terminal receipt history remains bounded even after physical capacity is released', async () => { + const { requests, db } = await fixture() + for (let n = 0; n < 4; n++) { + const input = request(n.toString(16).padStart(64, '0')) + await requests.claim(identity, input) + await requests.close(identity, input.requestId) + } + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await expect(requests.claim(identity, request())).rejects.toThrow('occupied') + expect(await db('snapshot_archive_requests')).toHaveLength(4) +}) + +test('schema removal refuses outstanding reservations and repeated creation preserves receipts', async () => { + const { db, requests } = await fixture() + const input = request() + await requests.claim(identity, input) + await addSnapshotArchiveRequestTable(db) + expect((await requests.status(identity, input.requestId)).state).toBe('building') + await expect(removeSnapshotArchiveRequestTable(db)).rejects.toThrow('Close snapshot archive requests') + await requests.close(identity, input.requestId) + await removeSnapshotArchiveRequestTable(db) + await removeSnapshotArchiveRequestTable(db) + expect(await db.schema.hasTable('snapshot_archive_requests')).toBe(false) +}) + +test('an actually expired request stays unavailable after its receipt has been collected', async () => { + const { db, requests } = await fixture() + const input = { ...request(), notAfter: Date.now() + 100 } + input.requestId = snapshotArchiveRequestId(input) + await requests.claim(identity, input) + await new Promise(resolve => setTimeout(resolve, 150)) + expect((await requests.status(identity, input.requestId)).state).toBe('expired') + await requests.reap() + expect(await db('snapshot_archive_requests')).toHaveLength(0) + await expect(requests.claim(identity, input)).rejects.toThrow('Invalid snapshot archive creation request') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('global terminal history bounds many distinct profiles without consuming released capacity', async () => { + const { db, requests } = await fixture() + for (let n = 0; n < 64; n++) { + const owner = '02' + n.toString(16).padStart(64, '0') + const input = request(n.toString(16).padStart(64, '0')) + await requests.claim(owner, input) + await requests.close(owner, input.requestId) + } + await expect(requests.claim(identity, request())).rejects.toThrow('occupied') + expect(await db('snapshot_archive_requests')).toHaveLength(64) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('capture ownership, profile and archive binding must all match before durable effects', async () => { + const { db, requests, archives } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + await expect(requests.begin({ ...admitted.owner!, claimToken: '0'.repeat(64) }, binding)).rejects.toThrow( + 'unavailable' + ) + await expect( + requests.begin(admitted.owner!, { ...binding, user: { ...binding.user, identityKey: other } }) + ).rejects.toThrow('unavailable') + expect(await db('snapshot_archives')).toHaveLength(0) + const writer = await requests.begin(admitted.owner!, binding) + await expect(requests.begin(admitted.owner!, binding)).rejects.toThrow('unavailable') + await append(archives, writer) + await expect(requests.seal(admitted.owner!, { ...writer, archiveId: '0'.repeat(64) })).rejects.toThrow('unavailable') + await expect(requests.seal(admitted.owner!, { ...writer, writerToken: '0'.repeat(64) })).rejects.toThrow( + 'unavailable' + ) + const manifest = await requests.seal(admitted.owner!, writer) + expect(await requests.seal(admitted.owner!, writer)).toEqual(manifest) + await requests.close(identity, input.requestId) + await expect(requests.seal(admitted.owner!, writer)).rejects.toThrow('unavailable') +}) + +test('a local archive that wins a profile race leaves the pending request reservation recoverable', async () => { + const { db, requests, archives } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + const local = await archives.begin(binding, { maxBytes: 8192 }) + await expect(requests.begin(admitted.owner!, binding)).rejects.toThrow('occupied') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 2, reservedBytes: 40960 }) + await requests.close(identity, input.requestId, 'failed') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 8192 }) + await archives.close(identity, local.archiveId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test.each(['', 'x' + identity, identity + 'x', 7, null])( + 'refuses malformed request profile %p before database work', + async value => { + const { requests, db } = await fixture() + await expect(requests.claim(value as string, request())).rejects.toThrow( + /identityKey.*compressed public identity key/ + ) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + } +) + +test.each(['', 'x' + 'a'.repeat(64), 'a'.repeat(64) + 'x', 7, null])( + 'refuses malformed request identifier %p', + async value => { + const { requests } = await fixture() + await expect(requests.status(identity, value as string)).rejects.toThrow( + /requestId.*version-one snapshot archive request identifier/ + ) + } +) + +test('ready profiles retain independent deadlines and cleanup ownership', async () => { + const { db, requests, archives } = await fixture() + const first = request() + const firstClaim = await requests.claim(identity, first) + const firstWriter = await requests.begin(firstClaim.owner!, binding) + await append(archives, firstWriter) + await requests.seal(firstClaim.owner!, firstWriter) + const next = { ...request('c'.repeat(64)), notAfter: first.notAfter - 60000 } + next.requestId = snapshotArchiveRequestId(next) + const nextClaim = await requests.claim(other, next) + const nextWriter = await requests.begin(nextClaim.owner!, { + ...binding, + user: { ...binding.user, identityKey: other } + }) + await append(archives, nextWriter) + const nextManifest = await requests.seal(nextClaim.owner!, nextWriter) + expect((await archives.inspect(identity, firstWriter.archiveId)).expiresAt).toBe(first.notAfter) + expect(nextManifest.expiresAt).toBe(next.notAfter) + await requests.close(identity, first.requestId) + expect((await requests.status(other, next.requestId)).state).toBe('ready') + expect((await db('snapshot_archive_requests').where({ identityKey: other }).first()).released).toBe(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + expect((await archives.inspect(other, nextWriter.archiveId)).digest).toBe(nextManifest.digest) + await requests.close(other, next.requestId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('existing local archives refuse request admission for their profile', async () => { + const { db, requests, archives } = await fixture() + const local = await archives.begin(binding, { maxBytes: 8192 }) + await expect(requests.claim(identity, request())).rejects.toThrow('occupied') + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 8192 }) + await archives.close(identity, local.archiveId) +}) + +test('byte reservations accept the exact global limit and reject the next capture', async () => { + const { db, requests } = await fixture() + for (let n = 0; n < 4; n++) + await requests.claim( + '02' + n.toString(16).padStart(64, '0'), + request(n.toString(16).padStart(64, '0'), 32 * 1024 * 1024) + ) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 4, reservedBytes: 128 * 1024 * 1024 }) + await expect(requests.claim(identity, request())).rejects.toThrow('occupied') + expect(await db('snapshot_archive_requests')).toHaveLength(4) +}) + +test.each(['closed', 'failed', 'expired'] as const)( + 'reaping resumes interrupted %s cleanup without collecting unexpired history', + async state => { + const { db, requests, archives } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + const writer = await requests.begin(admitted.owner!, binding) + await append(archives, writer) + await db.raw( + "CREATE TRIGGER fail_request_cleanup BEFORE DELETE ON snapshot_archive_pages BEGIN SELECT RAISE(ABORT, 'synthetic cleanup interruption'); END" + ) + await expect(requests.close(identity, input.requestId, state)).rejects.toThrow('synthetic cleanup interruption') + expect((await requests.status(identity, input.requestId)).state).toBe(state) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + expect((await db('snapshot_archive_requests').first()).released).toBe(0) + await db.raw('DROP TRIGGER fail_request_cleanup') + await requests.reap() + expect(await db('snapshot_archive_requests')).toHaveLength(1) + expect((await db('snapshot_archive_requests').first()).released).toBe(1) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + } +) + +test('a creation request expires exactly at its immutable database-clock deadline', async () => { + const { db, requests } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + const clock = jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(input.notAfter) + try { + expect((await requests.status(identity, input.requestId)).state).toBe('expired') + await expect(requests.begin(admitted.owner!, binding)).rejects.toThrow('unavailable') + await requests.reap() + expect(await db('snapshot_archive_requests')).toHaveLength(0) + } finally { + clock.mockRestore() + } +}) + +test.each([0, 1])('admission rechecks %i remaining milliseconds after ownership lookup', async remaining => { + const { db, requests } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + const clock = jest + .spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow') + .mockResolvedValueOnce(input.notAfter - 1) + .mockResolvedValue(input.notAfter - remaining) + try { + const pending = requests.begin(admitted.owner!, binding) + if (remaining === 0) { + await expect(pending).rejects.toThrow('unavailable') + expect(await db('snapshot_archives')).toHaveLength(0) + } else { + const writer = await pending + expect((await db('snapshot_archives').where({ archiveId: writer.archiveId }).first()).expiresAt).toBe( + input.notAfter + ) + } + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + } finally { + clock.mockRestore() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts new file mode 100644 index 000000000..ce42a0ebd --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts @@ -0,0 +1,228 @@ +import { Random, Utils } from '@bsv/sdk' +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' +import { + snapshotArchiveLimits, + type SnapshotArchiveBinding, + type SnapshotArchiveWriter, + type SnapshotArchiveManifest +} from './SnapshotArchive' +import { lockSnapshotArchiveCapacity, snapshotArchiveDatabaseNow } from './SnapshotArchiveSql' +import { + parseSnapshotArchiveRequest, + validateSnapshotArchiveRequest, + type SnapshotArchiveRequestOwner, + type SnapshotArchiveRequestReceipt +} from './SnapshotArchiveRequest' + +interface RequestRow { + identityKey: string + requestId: string + claimToken: string + state: 'claimed' | 'capturing' | 'ready' | 'closed' | 'failed' | 'expired' + requestJson: string + expiresAt: number | string + reservedBytes: number | string + archiveId: string | null + released: boolean | number +} + +const table = 'snapshot_archive_requests' +export const snapshotArchiveRequestLimits = Object.freeze({ perProfile: 4, total: 64 }) + +function unavailable(): never { + throw new WERR_INVALID_OPERATION('Snapshot archive request is unavailable') +} + +function identity(value: string): void { + if (typeof value !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(value)) { + throw new WERR_INVALID_PARAMETER('identityKey', 'a compressed public identity key') + } +} + +function identifier(value: string): void { + if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) { + throw new WERR_INVALID_PARAMETER('requestId', 'a version-one snapshot archive request identifier') + } +} + +/** Durable deduplication with admission charged before opening a source pool. */ +export class KnexSnapshotArchiveRequestStore { + constructor(private readonly knex: Knex) {} + + private async receipt(k: Knex, row: RequestRow): Promise { + const base = { version: 1 as const, requestId: row.requestId, expiresAt: Number(row.expiresAt) } + if (base.expiresAt <= (await snapshotArchiveDatabaseNow(k))) return { ...base, state: 'expired' } + if (row.state === 'ready') { + if (row.archiveId === null) unavailable() + const archive = await new KnexSnapshotArchiveStore(k).inspect(row.identityKey, row.archiveId) + return { ...base, state: 'ready', archiveId: archive.archiveId, digest: archive.digest } + } + return { ...base, state: row.state === 'claimed' || row.state === 'capturing' ? 'building' : row.state } + } + + async claim( + identityKey: string, + input: unknown + ): Promise<{ receipt: SnapshotArchiveRequestReceipt; owner?: SnapshotArchiveRequestOwner }> { + identity(identityKey) + const request = parseSnapshotArchiveRequest(input) + const requestJson = JSON.stringify(request) + return await this.knex.transaction(async trx => { + const capacity = await lockSnapshotArchiveCapacity(trx) + const now = await snapshotArchiveDatabaseNow(trx) + validateSnapshotArchiveRequest(request, now) + const existing: RequestRow | undefined = await trx(table) + .where({ identityKey, requestId: request.requestId }) + .first() + if (existing !== undefined) { + if (existing.requestJson !== requestJson) unavailable() + return { receipt: await this.receipt(trx, existing) } + } + await trx(table).where('expiresAt', '<=', now).where({ released: true }).delete() + const retained: Array> = await trx(table) + .select('identityKey', 'released') + .limit(snapshotArchiveRequestLimits.total) + const owned = retained.filter(row => row.identityKey === identityKey) + const archive = await trx('snapshot_archives').where({ identityKey }).first('archiveId') + if ( + retained.length >= snapshotArchiveRequestLimits.total || + owned.length >= snapshotArchiveRequestLimits.perProfile || + owned.some(row => !row.released) || + archive !== undefined || + capacity.archives >= snapshotArchiveLimits.archives || + Number(capacity.reservedBytes) + request.maxBytes > snapshotArchiveLimits.totalBytes + ) + throw new SnapshotResourceLimitError('Snapshot archive request capacity is occupied') + const owner = { identityKey, requestId: request.requestId, claimToken: Utils.toHex(Random(32)) } + const row: RequestRow = { + ...owner, + state: 'claimed', + requestJson, + expiresAt: request.notAfter, + reservedBytes: request.maxBytes, + archiveId: null, + released: false + } + await trx(table).insert(row) + await trx('snapshot_archive_capacity') + .where({ id: 1 }) + .update({ archives: capacity.archives + 1, reservedBytes: Number(capacity.reservedBytes) + request.maxBytes }) + return { receipt: await this.receipt(trx, row), owner } + }) + } + + async status(identityKey: string, requestId: string): Promise { + identity(identityKey) + identifier(requestId) + const row: RequestRow | undefined = await this.knex(table).where({ identityKey, requestId }).first() + if (row === undefined) unavailable() + return await this.receipt(this.knex, row) + } + + private async owned(k: Knex, owner: SnapshotArchiveRequestOwner): Promise { + const row: RequestRow | undefined = await k(table).where(owner).first() + if (row === undefined || row.released || Number(row.expiresAt) <= (await snapshotArchiveDatabaseNow(k))) + unavailable() + return row + } + + /** The claim-to-archive assignment and accounting transition commit together. */ + async begin(owner: SnapshotArchiveRequestOwner, binding: SnapshotArchiveBinding): Promise { + const claim = { ...owner } + return await this.knex.transaction(async trx => { + const capacity = await lockSnapshotArchiveCapacity(trx) + const row = await this.owned(trx, claim) + if (row.state !== 'claimed' || row.archiveId !== null || binding.user.identityKey !== row.identityKey) + unavailable() + const remaining = Number(row.expiresAt) - (await snapshotArchiveDatabaseNow(trx)) + if (remaining < 1) unavailable() + await trx('snapshot_archive_capacity') + .where({ id: 1 }) + .update({ + archives: capacity.archives - 1, + reservedBytes: Number(capacity.reservedBytes) - Number(row.reservedBytes) + }) + const writer = await new KnexSnapshotArchiveStore(trx).begin(binding, { + maxBytes: Number(row.reservedBytes), + lifetimeMs: remaining + }) + await trx('snapshot_archives').where({ archiveId: writer.archiveId }).update({ expiresAt: row.expiresAt }) + await trx(table).where(claim).update({ state: 'capturing', archiveId: writer.archiveId }) + return writer + }) + } + + async seal(owner: SnapshotArchiveRequestOwner, writer: SnapshotArchiveWriter): Promise { + const claim = { ...owner } + const captured = { ...writer } + return await this.knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + const row = await this.owned(trx, claim) + if (!['capturing', 'ready'].includes(row.state) || row.archiveId !== captured.archiveId) unavailable() + const manifest = await new KnexSnapshotArchiveStore(trx).seal(captured) + await trx(table).where(claim).update({ state: 'ready' }) + return manifest + }) + } + + async close( + identityKey: string, + requestId: string, + state: 'closed' | 'failed' | 'expired' = 'closed' + ): Promise { + identity(identityKey) + identifier(requestId) + await this.knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + const row: RequestRow | undefined = await trx(table).where({ identityKey, requestId }).first() + if (row !== undefined && ['claimed', 'capturing', 'ready'].includes(row.state)) { + await trx(table).where({ identityKey, requestId }).update({ state }) + } + }) + await this.release(identityKey, requestId) + } + + private async release(identityKey: string, requestId: string): Promise { + const archiveId = await this.knex.transaction(async trx => { + const capacity = await lockSnapshotArchiveCapacity(trx) + const row: RequestRow | undefined = await trx(table).where({ identityKey, requestId }).first() + if (row === undefined || row.released || !['closed', 'failed', 'expired'].includes(row.state)) return undefined + if (row.archiveId !== null) return row.archiveId + await trx('snapshot_archive_capacity') + .where({ id: 1 }) + .update({ + archives: capacity.archives - 1, + reservedBytes: Number(capacity.reservedBytes) - Number(row.reservedBytes) + }) + await trx(table).where({ identityKey, requestId }).update({ released: true }) + return undefined + }) + if (archiveId === undefined) return + await new KnexSnapshotArchiveStore(this.knex).close(identityKey, archiveId) + await this.knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + await trx(table).where({ identityKey, requestId, archiveId }).update({ released: true }) + }) + } + + async reap(): Promise { + const now = await snapshotArchiveDatabaseNow(this.knex) + const rows: RequestRow[] = await this.knex(table) + .where(query => { + void query.where('expiresAt', '<=', now).orWhereIn('state', ['closed', 'failed', 'expired']) + }) + .limit(snapshotArchiveRequestLimits.total) + await runInSeries(rows, async row => { + if (Number(row.expiresAt) <= now) await this.close(row.identityKey, row.requestId, 'expired') + else await this.release(row.identityKey, row.requestId) + }) + await this.knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + await trx(table).where('expiresAt', '<=', now).where({ released: true }).delete() + }) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts new file mode 100644 index 000000000..69567ee49 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts @@ -0,0 +1,336 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' +import { KnexSnapshotArchiveService } from './KnexSnapshotArchiveService' +import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' +import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage } from './SnapshotArchiveDirectory' +import * as ArchiveSql from './SnapshotArchiveSql' + +const identity = '02' + '11'.repeat(32) +const other = '03' + '22'.repeat(32) +const stores: StorageKnex[] = [] +const services: KnexSnapshotArchiveService[] = [] +const directories: string[] = [] +function gate() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} +function service(storage: StorageKnex) { + const controller = new KnexSnapshotArchiveService(storage) + services.push(controller) + return controller +} +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-service-')) + directories.push(directory) + const open = () => { + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + }) + stores.push(storage) + return storage + } + const storage = open() + await storage.knex.raw('PRAGMA journal_mode = WAL') + await storage.migrate('service source', 'service-source') + await storage.makeAvailable() + const { user } = await storage.findOrInsertUser(identity) + const { user: peer } = await storage.findOrInsertUser(other) + await seedArchiveClosure(storage, user.userId, peer.userId) + return { storage, controller: service(storage), open } +} +function request(nonce = 'b'.repeat(64)) { + const fields = { version: 1 as const, nonce, notAfter: Date.now() + 300000, maxBytes: 32768 } + return { ...fields, requestId: snapshotArchiveRequestId(fields) } +} +afterEach(async () => { + jest.restoreAllMocks() + await Promise.all(services.splice(0).map(controller => controller.close().catch(() => undefined))) + await Promise.all(stores.splice(0).map(storage => storage.destroy())) + await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +test('capture reserves before reader acquisition and a replacement server recovers the exact immutable archive', async () => { + const { storage, controller, open } = await fixture() + const input = request() + const original = storage.openSnapshotArchiveSource.bind(storage) + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options) => { + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + expect((await controller.status(identity, input.requestId)).state).toBe('building') + expect(options?.signal).toBeInstanceOf(AbortSignal) + expect(options?.lifetimeMs).toBeGreaterThan(0) + expect(options?.lifetimeMs).toBeLessThanOrEqual(300000) + return await original(key, options) + }) + const pending = controller.create(identity, input) + expect(controller.create(identity, { ...input })).toBe(pending) + expect(() => controller.create(other, input)).toThrow('opening or active') + expect(() => controller.create(identity, request('c'.repeat(64)))).toThrow('opening or active') + const ready = await pending + expect(ready).toMatchObject({ state: 'ready', requestId: input.requestId, expiresAt: input.notAfter }) + expect(opening).toHaveBeenCalledTimes(1) + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() + const directory = await controller.directory(identity, ready.archiveId!) + const verified = verifySnapshotArchiveDirectory(directory, { + identityKey: identity, + chain: 'test', + sourceStorageIdentityKey: 'service-source', + digest: ready.digest + }) + expect(verified.manifest.pages).toBe(13) + const page = await controller.read(identity, ready.archiveId!, 8) + expect(verifySnapshotArchivePage(page, verified.receipts[8])).toEqual(page.bytes) + await expect(controller.directory(other, ready.archiveId!)).rejects.toThrow('unavailable') + await expect(controller.read(other, ready.archiveId!, 8)).rejects.toThrow('unavailable') + await expect(controller.status(other, input.requestId)).rejects.toThrow('unavailable') + await controller.cancel(other, input.requestId) + expect(await controller.status(identity, input.requestId)).toEqual(ready) + await controller.close() + const replacementStorage = open() + await replacementStorage.makeAvailable() + const replacement = service(replacementStorage) + const replacementOpen = jest.spyOn(replacementStorage, 'openSnapshotArchiveSource') + expect(await replacement.create(identity, input)).toEqual(ready) + expect(replacementOpen).not.toHaveBeenCalled() + expect(await replacement.directory(identity, ready.archiveId!)).toEqual(directory) + expect(await replacement.read(identity, ready.archiveId!, 8)).toEqual(page) + await replacement.cancel(identity, input.requestId) + expect((await replacement.create(identity, input)).state).toBe('closed') + expect(replacementOpen).not.toHaveBeenCalled() + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test.each(['cancel', 'shutdown'] as const)( + '%s during reader opening waits for physical destruction before releasing quota', + async action => { + const { storage, controller } = await fixture() + const input = request() + const entered = gate() + const allowOpen = gate() + const destroying = gate() + const allowDestroy = gate() + const original = storage.openSnapshotArchiveSource.bind(storage) + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options) => { + // Acquire the actual view, but withhold it from the controller until cancellation. + const source = (await original(key, { ...options, signal: undefined }))! + const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex + const destroy = reader.destroy.bind(reader) + jest.spyOn(reader, 'destroy').mockImplementation(async () => { + destroying.resolve() + await allowDestroy.promise + await destroy() + }) + entered.resolve() + await allowOpen.promise + return source + }) + const capture = controller.create(identity, input) + void capture.catch(() => undefined) + let stopping: Promise | undefined + try { + await entered.promise + let finished = false + stopping = (action === 'cancel' ? controller.cancel(identity, input.requestId) : controller.close()).then(() => { + finished = true + }) + void stopping.catch(() => undefined) + await Promise.resolve() + expect(finished).toBe(false) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + allowOpen.resolve() + await destroying.promise + expect(finished).toBe(false) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + await storage.knex('tx_labels').where({ txLabelId: 1 }).update({ label: 'foreground during cancellation' }) + allowDestroy.resolve() + await stopping + await expect(capture).rejects.toThrow('cancelled') + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() + expect((await new KnexSnapshotArchiveRequestStore(storage.knex).status(identity, input.requestId)).state).toBe( + 'closed' + ) + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + expect(await storage.knex('snapshot_archive_pages')).toHaveLength(0) + if (action === 'cancel') { + expect((await controller.create(identity, input)).state).toBe('closed') + jest.restoreAllMocks() + expect((await controller.create(identity, request('c'.repeat(64)))).state).toBe('ready') + } else { + expect(() => controller.create(identity, request('c'.repeat(64)))).toThrow('closed') + } + } finally { + allowOpen.resolve() + allowDestroy.resolve() + await capture.catch(() => undefined) + await stopping?.catch(() => undefined) + } + } +) + +test('shutdown before the admission microtask prevents a claim and remains idempotent', async () => { + const { storage, controller } = await fixture() + const input = request() + const capture = controller.create(identity, input) + const closing = controller.close() + expect(controller.close()).toBe(closing) + await closing + await expect(capture).rejects.toThrow('cancelled') + expect(await storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) + expect(() => controller.create(identity, input)).toThrow('closed') + await expect(controller.status(identity, input.requestId)).rejects.toThrow('closed') + await expect(controller.directory(identity, 'a'.repeat(64))).rejects.toThrow('closed') + await expect(controller.read(identity, 'a'.repeat(64), 0)).rejects.toThrow('closed') + await expect(controller.cancel(identity, input.requestId)).rejects.toThrow('closed') +}) + +test('idle shutdown preserves ready archives and closing never destroys the foreground provider', async () => { + const { storage, controller } = await fixture() + const input = request() + const ready = await controller.create(identity, input) + const closing = controller.close() + expect(controller.close()).toBe(closing) + await closing + const replacement = service(storage) + expect(await replacement.status(identity, input.requestId)).toEqual(ready) + expect((await replacement.directory(identity, ready.archiveId!)).archiveId).toBe(ready.archiveId) + expect(await storage.knex('users')).toHaveLength(2) +}) + +test('capture failure closes the reader, records a terminal failure and allows a different request', async () => { + const { storage, controller } = await fixture() + const input = request() + const original = storage.openSnapshotArchiveSource.bind(storage) + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options) => { + const source = (await original(key, options))! + return { + ...source, + validateClosure: async () => { + throw new Error('fixture closure failure') + } + } + }) + await expect(controller.create(identity, input)).rejects.toThrow('fixture closure failure') + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() + expect((await controller.create(identity, input)).state).toBe('failed') + expect(opening).toHaveBeenCalledTimes(1) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) + expect((await controller.create(identity, request('c'.repeat(64)))).state).toBe('ready') +}) + +test('failed physical cleanup retains the reservation and fences the controller including later shutdown', async () => { + const { storage, controller } = await fixture() + const input = request() + const original = storage.openSnapshotArchiveSource.bind(storage) + const failure = new Error('physical close failed') + let source: Awaited> + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options) => { + source = (await original(key, options))! + return { + ...source, + close: async () => { + throw failure + } + } + }) + await expect(controller.create(identity, input)).rejects.toBe(failure) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + expect((await storage.knex('snapshot_archives').first()).state).toBe('building') + expect(() => controller.create(identity, request('c'.repeat(64)))).toThrow('closed') + await expect(controller.close()).rejects.toBe(failure) + await source!.close() + await new KnexSnapshotArchiveRequestStore(storage.knex).close(identity, input.requestId, 'failed') +}) + +test('unsupported and busy sources fail without disturbing an existing local source', async () => { + const { storage, controller } = await fixture() + const local = (await storage.getSnapshotSync()!.openSource(identity))! + const input = request() + await expect(controller.create(identity, input)).rejects.toThrow('opening or active') + expect(local.isOpen).toBe(true) + expect((await controller.status(identity, input.requestId)).state).toBe('failed') + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) + await local.close() + jest.spyOn(storage, 'openSnapshotArchiveSource').mockResolvedValueOnce(undefined) + const unsupported = request('c'.repeat(64)) + await expect(controller.create(identity, unsupported)).rejects.toThrow('requires SQLite WAL or MySQL') + expect((await controller.status(identity, unsupported.requestId)).state).toBe('failed') + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) +}) + +test('an already claimed request is not resumed under a replacement source', async () => { + const { storage, controller } = await fixture() + const input = request() + const claimed = await new KnexSnapshotArchiveRequestStore(storage.knex).claim(identity, input) + const open = jest.spyOn(storage, 'openSnapshotArchiveSource') + expect(await controller.create(identity, input)).toEqual(claimed.receipt) + expect(open).not.toHaveBeenCalled() + await controller.cancel(identity, input.requestId) + expect((await controller.create(identity, input)).state).toBe('closed') +}) + +test('expiry between durable admission and source acquisition refuses without opening a pool', async () => { + const { storage, controller } = await fixture() + const input = request() + const now = jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow') + now + .mockResolvedValueOnce(input.notAfter - 100) + .mockResolvedValueOnce(input.notAfter - 100) + .mockResolvedValueOnce(input.notAfter) + const open = jest.spyOn(storage, 'openSnapshotArchiveSource') + await expect(controller.create(identity, input)).rejects.toThrow('expired before capture') + expect(open).not.toHaveBeenCalled() + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) +}) + +test('invalid requests cannot occupy the local slot or shared quota', async () => { + const { storage, controller } = await fixture() + expect(() => controller.create(identity, { ...request(), writerToken: 'untrusted' })).toThrow('Invalid') + await expect(controller.create('not-a-profile', request())).rejects.toThrow('identityKey') + expect(await storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect((await controller.create(identity, request())).state).toBe('ready') +}) + +test('failed cleanup while opening retains admission even though no source was returned', async () => { + const { storage, controller } = await fixture() + const input = request() + const failure = new Error('owned pool destruction failed') + const destroy = StorageKnex.prototype.destroy + jest.spyOn(StorageKnex.prototype, 'openReadSnapshot').mockRejectedValueOnce(new Error('reader initialization failed')) + jest.spyOn(StorageKnex.prototype, 'destroy').mockImplementation(async function (this: StorageKnex) { + if (this !== storage) throw failure + await destroy.call(this) + }) + let received: unknown + try { + await controller.create(identity, input) + } catch (error) { + received = error + } + expect(received).toMatchObject({ name: 'SnapshotArchiveSourceCleanupError', cause: failure }) + expect((received as Error).message).toBe('Snapshot archive source cleanup failed') + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + expect(await storage.knex('snapshot_archives')).toHaveLength(0) + expect((await storage.knex('snapshot_archive_requests').first()).state).toBe('claimed') + expect(await storage.supportsSnapshotArchiveSource()).toBe(false) + expect(() => controller.create(identity, request('c'.repeat(64)))).toThrow('closed') + await expect(controller.close()).rejects.toBe(received) + jest.restoreAllMocks() + const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex + await reader.destroy() + await new KnexSnapshotArchiveRequestStore(storage.knex).close(identity, input.requestId, 'failed') +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts new file mode 100644 index 000000000..8975d3f57 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts @@ -0,0 +1,165 @@ +import { WERR_INVALID_OPERATION, WERR_NOT_IMPLEMENTED } from '../../../sdk/WERR_errors' +import type { StorageKnex } from '../../StorageKnex' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { SnapshotArchiveSourceCleanupError, type SnapshotArchiveSource } from './KnexSnapshotArchiveSource' +import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' +import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' +import { + parseSnapshotArchiveRequest, + type SnapshotArchiveRequest, + type SnapshotArchiveRequestReceipt +} from './SnapshotArchiveRequest' +import { snapshotArchiveDatabaseNow } from './SnapshotArchiveSql' +import { assertSnapshotArchiveCaptureActive, captureSnapshotArchiveSource } from './captureSnapshotArchiveSource' + +interface Capture { + identityKey: string + request: Readonly + controller: AbortController + terminal: 'closed' | 'failed' + completion: Promise +} + +/** Internal SQL controller. Authentication and bounded HTTP framing belong to its caller. */ +export class KnexSnapshotArchiveService { + private readonly requests: KnexSnapshotArchiveRequestStore + private readonly archives: KnexSnapshotArchiveStore + private active?: Capture + private stopped = false + private closing?: Promise + private cleanupFailure?: { error: unknown } + + constructor(private readonly storage: StorageKnex) { + this.requests = new KnexSnapshotArchiveRequestStore(storage.knex) + this.archives = new KnexSnapshotArchiveStore(storage.knex) + } + + private assertOpen(): void { + if (this.stopped) throw new WERR_INVALID_OPERATION('Snapshot archive service is closed') + } + + private failedCleanup(error: unknown): never { + this.cleanupFailure = { error } + this.stopped = true + throw error + } + + /** + * Admission is synchronous, before the first database or pool await. A retry + * keeps its fixed request ID; another process can recover its durable receipt. + * Completion waits for capture; status remains available while it runs. + */ + create(identityKey: string, input: unknown): Promise { + this.assertOpen() + const request = parseSnapshotArchiveRequest(input) + if (this.active !== undefined) { + if (this.active.identityKey === identityKey && this.active.request.requestId === request.requestId) + return this.active.completion + throw new SnapshotResourceLimitError('Snapshot archive capture is opening or active') + } + const completion = Promise.resolve() + .then(() => this.capture(job)) + .finally(() => { + if (this.active === job) this.active = undefined + }) + const job: Capture = { + identityKey, + request, + controller: new AbortController(), + terminal: 'failed', + completion + } + this.active = job + // Cancellation/shutdown can observe completion even after a caller disconnects. + void completion.catch(() => undefined) + return completion + } + + private async capture(job: Capture): Promise { + const { identityKey, request, controller } = job + assertSnapshotArchiveCaptureActive(controller.signal) + const claimed = await this.requests.claim(identityKey, request) + if (claimed.owner === undefined) return claimed.receipt + let source: SnapshotArchiveSource | undefined + const cleanup = async (): Promise => { + try { + // Keep the logical reservation through this process's physical cleanup. + await source?.close() + await this.requests.close(identityKey, request.requestId, job.terminal) + } catch (error) { + this.failedCleanup(error) + } + } + try { + assertSnapshotArchiveCaptureActive(controller.signal) + const remaining = request.notAfter - (await snapshotArchiveDatabaseNow(this.storage.knex)) + if (remaining < 1) throw new SnapshotResourceLimitError('Snapshot archive request expired before capture') + source = await this.storage.openSnapshotArchiveSource(identityKey, { + signal: controller.signal, + lifetimeMs: Math.min(300000, remaining) + }) + if (source === undefined) throw new WERR_NOT_IMPLEMENTED('Snapshot archive capture requires SQLite WAL or MySQL') + const owner = claimed.owner + await captureSnapshotArchiveSource( + source, + { + begin: binding => this.requests.begin(owner, binding), + append: (writer, page) => this.archives.append(writer, page), + seal: writer => this.requests.seal(owner, writer), + close: cleanup + }, + identityKey, + this.storage.chain, + { signal: controller.signal } + ) + return await this.requests.status(identityKey, request.requestId) + } catch (error) { + if (error instanceof SnapshotArchiveSourceCleanupError) this.failedCleanup(error) + await cleanup() + throw error + } + } + + async status(identityKey: string, requestId: string): Promise { + this.assertOpen() + return await this.requests.status(identityKey, requestId) + } + + async directory(identityKey: string, archiveId: string) { + this.assertOpen() + return await this.archives.directory(identityKey, archiveId) + } + + async read(identityKey: string, archiveId: string, sequence: number) { + this.assertOpen() + return await this.archives.read(identityKey, archiveId, sequence) + } + + private async stop(job: Capture): Promise { + job.terminal = 'closed' + job.controller.abort() + await job.completion.catch(() => undefined) + if (this.cleanupFailure !== undefined) throw this.cleanupFailure.error + } + + async cancel(identityKey: string, requestId: string): Promise { + this.assertOpen() + const job = this.active + if (job?.identityKey === identityKey && job.request.requestId === requestId) await this.stop(job) + await this.requests.close(identityKey, requestId) + } + + /** Fence admission and drain the owned capture; completed archives survive shutdown. */ + close(): Promise { + this.stopped = true + if (this.closing === undefined) { + const job = this.active + this.closing = job === undefined ? this.closedWithoutCapture() : this.stop(job) + } + return this.closing + } + + private async closedWithoutCapture(): Promise { + if (this.cleanupFailure !== undefined) throw this.cleanupFailure.error + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index 3fabbbae2..f061071b8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -6,6 +6,14 @@ import { createKnexWalletSnapshotPageReader } from '../KnexWalletReadSnapshot' import type { WalletReadSnapshot, WalletReadSnapshotOptions } from '../WalletReadSnapshot' import { assertKnexSnapshotArchiveClosure } from './KnexSnapshotArchiveClosure' +/** Internal ownership failure; no caller may release its admission as cleaned up. */ +export class SnapshotArchiveSourceCleanupError extends Error { + constructor(override readonly cause: unknown) { + super('Snapshot archive source cleanup failed') + this.name = 'SnapshotArchiveSourceCleanupError' + } +} + export interface SnapshotArchiveSource extends WalletReadSnapshot { readonly sourceSchema: string /** Verify profile relations using the same read view as the header and pages. */ diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index 6d8bfda9c..d43373639 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -631,7 +631,7 @@ test('only acknowledged sequence positions are readable, even if an unacknowledg test('the auxiliary migration is registered after the durable sync schema', async () => { const migrations = new KnexMigrations('test', 'source', 'source', 1024) - expect(await migrations.getLatestMigration()).toBe('2026-09-30-002 add snapshot archive staging') + expect(await migrations.getLatestMigration()).toBe('2026-09-30-003 add snapshot archive requests') }) test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts index 0a4ed08d6..6bc9e925f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts @@ -4,6 +4,7 @@ import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WER import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' import type { WalletSnapshotTable } from '../WalletReadSnapshot' import { runInSeries } from '../../../utility/runInSeries' +import { lockSnapshotArchiveCapacity, snapshotArchiveDatabaseNow } from './SnapshotArchiveSql' import { snapshotArchiveEncoding, type SnapshotArchiveDirectory } from './SnapshotArchiveDirectory' import { @@ -92,26 +93,11 @@ export class KnexSnapshotArchiveStore { constructor(private readonly knex: Knex) {} private async now(k: Knex): Promise { - const mysql = String(k.client.config.client).includes('mysql') - if (mysql) { - const [rows]: Array> = await k.raw( - 'SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000) AS now' - ) - return Number(rows[0].now) - } - const rows: Array<{ now: number }> = await k.raw( - "SELECT CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER) AS now" - ) - return rows[0].now + return await snapshotArchiveDatabaseNow(k) } private async capacity(k: Knex): Promise<{ archives: number; reservedBytes: number | string }> { - // A harmless write acquires SQLite's writer reservation before any reads. - // MySQL takes this exact row lock, shared by all staging state mutations. - await k('snapshot_archive_capacity').where({ id: 1 }).update({ id: 1 }) - const row = await k('snapshot_archive_capacity').where({ id: 1 }).first() - if (row === undefined) throw new WERR_INVALID_OPERATION('Snapshot archive schema is unavailable') - return row + return await lockSnapshotArchiveCapacity(k) } async begin( diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.test.ts new file mode 100644 index 000000000..713ec4769 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.test.ts @@ -0,0 +1,125 @@ +import { createHash } from 'node:crypto' +import { + parseSnapshotArchiveRequest, + snapshotArchiveRequestId, + validateSnapshotArchiveRequest +} from './SnapshotArchiveRequest' + +const now = Date.UTC(2026, 8, 30) +function request() { + const value = { version: 1 as const, nonce: 'a'.repeat(64), notAfter: now + 300000, maxBytes: 32768 } + const requestId = createHash('sha256') + .update(JSON.stringify(['wallet-snapshot-request/1', 1, value.nonce, value.notAfter, value.maxBytes])) + .digest('hex') + return { ...value, requestId } +} + +test('binds every creation option and the immutable deadline to an independent digest', () => { + const value = request() + expect(snapshotArchiveRequestId(value)).toBe(value.requestId) + const parsed = validateSnapshotArchiveRequest(value, now) + expect(parsed).toEqual(value) + expect(parsed).not.toBe(value) + expect(Object.isFrozen(parsed)).toBe(true) + value.nonce = 'b'.repeat(64) + expect(parsed.nonce).toBe('a'.repeat(64)) +}) + +test.each(['nonce', 'notAfter', 'maxBytes'] as const)('a changed %s cannot reuse its previous request ID', key => { + const value = request() + const changed = key === 'nonce' ? 'b'.repeat(64) : value[key] + 1 + expect(() => validateSnapshotArchiveRequest({ ...value, [key]: changed }, now)).toThrow( + 'Invalid snapshot archive creation request' + ) +}) + +test.each([now + 300000, now + 300001])( + 'an expired request cannot reopen at %s after its receipt is collected', + clock => { + expect(() => validateSnapshotArchiveRequest(request(), clock)).toThrow('Invalid snapshot archive creation request') + } +) + +test.each([1, 3600000])('accepts a bounded remaining lifetime of %i milliseconds', lifetime => { + const value = { ...request(), notAfter: now + lifetime } + value.requestId = snapshotArchiveRequestId(value) + expect(validateSnapshotArchiveRequest(value, now).notAfter).toBe(value.notAfter) +}) + +test.each([4097, 32 * 1024 * 1024])('accepts a reservation of exactly %i bytes', maxBytes => { + const value = { ...request(), maxBytes } + value.requestId = snapshotArchiveRequestId(value) + expect(validateSnapshotArchiveRequest(value, now).maxBytes).toBe(maxBytes) +}) + +test.each([ + ['version', 2], + ['nonce', 'A'.repeat(64)], + ['nonce', 'a'.repeat(63)], + ['nonce', 'a'.repeat(65)], + ['nonce', 1], + ['notAfter', 0], + ['notAfter', NaN], + ['notAfter', Infinity], + ['notAfter', 0.5], + ['notAfter', 'timestamp'], + ['notAfter', now + 3600001], + ['maxBytes', 4096], + ['maxBytes', 32 * 1024 * 1024 + 1], + ['maxBytes', NaN], + ['maxBytes', '32768'], + ['maxBytes', 32768.5], + ['requestId', 'b'.repeat(64)] +])('refuses malformed request %s=%p even with a recomputed ID', (key, changed) => { + const value = { ...request(), [key]: changed } + if (key !== 'requestId') value.requestId = snapshotArchiveRequestId(value as ReturnType) + expect(() => validateSnapshotArchiveRequest(value, now)).toThrow('Invalid snapshot archive creation request') +}) + +test.each([null, undefined, 1, 'request', []])('refuses a non-record request %p', value => { + expect(() => parseSnapshotArchiveRequest(value)).toThrow('Invalid snapshot archive creation request') +}) + +test.each([NaN, Infinity, -1, 0.5])('refuses an invalid database clock %s', clock => { + expect(() => validateSnapshotArchiveRequest(request(), clock)).toThrow('Invalid snapshot archive creation request') +}) + +test('requires exact own data fields without invoking request accessors', () => { + const value = request() + expect(() => parseSnapshotArchiveRequest({ ...value, claimToken: 'secret' })).toThrow( + 'Invalid snapshot archive creation request' + ) + expect(() => parseSnapshotArchiveRequest({ ...value, [Symbol('extra')]: true })).toThrow( + 'Invalid snapshot archive creation request' + ) + let reads = 0 + Object.defineProperty(value, 'nonce', { + enumerable: true, + get: () => { + reads++ + return 'a'.repeat(64) + } + }) + expect(() => parseSnapshotArchiveRequest(value)).toThrow('Invalid snapshot archive creation request') + expect(reads).toBe(0) + const hidden = request() + Object.defineProperty(hidden, 'nonce', { value: hidden.nonce, enumerable: false }) + expect(() => parseSnapshotArchiveRequest(hidden)).toThrow('Invalid snapshot archive creation request') + const renamed: Record = { ...request(), replacement: 'a'.repeat(64) } + delete renamed.nonce + expect(() => parseSnapshotArchiveRequest(renamed)).toThrow('Invalid snapshot archive creation request') +}) + +test('a different version cannot reuse the valid version-one digest', () => { + expect(() => parseSnapshotArchiveRequest({ ...request(), version: 2 })).toThrow( + 'Invalid snapshot archive creation request' + ) +}) + +test('supports the exact epoch boundary while refusing negative verifier time', () => { + const value = { ...request(), notAfter: 1 } + value.requestId = snapshotArchiveRequestId(value) + expect(validateSnapshotArchiveRequest(value, 0).notAfter).toBe(1) + expect(() => validateSnapshotArchiveRequest(value, -1)).toThrow('Invalid snapshot archive creation request') + expect(() => validateSnapshotArchiveRequest(value, 1)).toThrow('Invalid snapshot archive creation request') +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.ts new file mode 100644 index 000000000..061653c30 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.ts @@ -0,0 +1,83 @@ +import { syncTransferDigest } from '../../remoting/SyncTransfer' +import { snapshotArchiveLimits } from './SnapshotArchive' + +export interface SnapshotArchiveRequest { + version: 1 + nonce: string + notAfter: number + maxBytes: number + requestId: string +} + +export interface SnapshotArchiveRequestReceipt { + version: 1 + requestId: string + expiresAt: number + state: 'building' | 'ready' | 'closed' | 'failed' | 'expired' + archiveId?: string + digest?: string +} + +/** Internal admission ownership; never part of an RPC result or argument. */ +export interface SnapshotArchiveRequestOwner { + identityKey: string + requestId: string + claimToken: string +} + +function invalid(): never { + throw new TypeError('Invalid snapshot archive creation request') +} + +function fields(input: unknown): Omit { + if (input === null || typeof input !== 'object' || Array.isArray(input)) invalid() + const names = ['version', 'nonce', 'notAfter', 'maxBytes', 'requestId'] + if (Reflect.ownKeys(input).length !== names.length) invalid() + const data: Record = {} + for (const name of names) { + const field = Object.getOwnPropertyDescriptor(input, name) + if (field === undefined || !('value' in field) || !field.enumerable) invalid() + data[name] = field.value + } + if (data.version !== 1 || typeof data.nonce !== 'string' || !/^[0-9a-f]{64}$/.test(data.nonce)) invalid() + const notAfter = data.notAfter + const maxBytes = data.maxBytes + if (typeof notAfter !== 'number' || !Number.isSafeInteger(notAfter) || notAfter < 1) invalid() + if ( + typeof maxBytes !== 'number' || + !Number.isSafeInteger(maxBytes) || + maxBytes < snapshotArchiveLimits.headerCharge + 1 || + maxBytes > snapshotArchiveLimits.archiveBytes + ) + invalid() + const request = { version: 1 as const, nonce: data.nonce, notAfter, maxBytes } + if (data.requestId !== snapshotArchiveRequestId(request)) invalid() + return request +} + +/** A deadline is part of the request identity, so an expired ID cannot reopen. */ +export function snapshotArchiveRequestId(request: Omit): string { + return syncTransferDigest( + new TextEncoder().encode( + JSON.stringify(['wallet-snapshot-request/1', request.version, request.nonce, request.notAfter, request.maxBytes]) + ) + ) +} + +export function parseSnapshotArchiveRequest(input: unknown): Readonly { + const request = fields(input) + return Object.freeze({ ...request, requestId: snapshotArchiveRequestId(request) }) +} + +/** Validate against the database clock; callers obtain time from an authenticated offer. */ +export function validateSnapshotArchiveRequest(input: unknown, now: number): Readonly { + const request = parseSnapshotArchiveRequest(input) + if ( + !Number.isSafeInteger(now) || + now < 0 || + request.notAfter <= now || + request.notAfter - now > snapshotArchiveLimits.lifetimeMs + ) + invalid() + return request +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts new file mode 100644 index 000000000..5cc835708 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts @@ -0,0 +1,122 @@ +import fc from 'fast-check' +import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { addSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' +import { addSnapshotArchiveRequestTable } from '../../schema/snapshotArchiveRequestMigration' +import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' +import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' +import { snapshotArchiveTables, type SnapshotArchiveBinding } from './SnapshotArchive' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +test('generated creation/retry/close schedules preserve request identity, atomic publication and exact capacity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-request-property-')) + const open = () => + knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'requests.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const db = open() + const peer = open() + try { + await db.raw('PRAGMA journal_mode = WAL') + await addSnapshotArchiveTables(db) + await addSnapshotArchiveRequestTable(db) + const requests = new KnexSnapshotArchiveRequestStore(db) + const replacement = new KnexSnapshotArchiveRequestStore(peer) + const archives = new KnexSnapshotArchiveStore(db) + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: 2 }), + fc.integer({ min: 0, max: 13 }), + fc.integer({ min: 0, max: 3 }), + fc.uint8Array({ minLength: 1, maxLength: 16 }), + fc.boolean(), + async (stage, prefix, retries, bytes, fail) => { + const identityKey = '02' + bytes[0].toString(16).padStart(64, '0') + const other = '03' + '11'.repeat(32) + const notAfter = Date.now() + 300000 + const nonce = bytes[0].toString(16).padStart(64, '0') + const requestId = createHash('sha256') + .update(JSON.stringify(['wallet-snapshot-request/1', 1, nonce, notAfter, 32768])) + .digest('hex') + const request = { version: 1, nonce, notAfter, maxBytes: 32768, requestId } + const admitted = await requests.claim(identityKey, request) + expect(admitted.owner).toBeDefined() + expect(admitted.receipt.state).toBe('building') + for (let retry = 0; retry < retries; retry++) + expect(await replacement.claim(identityKey, request)).toEqual({ receipt: admitted.receipt }) + const date = new Date('2026-01-01T00:00:00.000Z') + const binding: SnapshotArchiveBinding = { + version: 1, + snapshotId: 'a'.repeat(64), + sourceSchema: 'property-v1', + sourceStorage: { + created_at: date, + updated_at: date, + chain: 'test', + dbtype: 'SQLite', + storageIdentityKey: 'source', + storageName: '', + maxOutputScript: 1024 + }, + user: { created_at: date, updated_at: date, userId: 1, identityKey, activeStorage: 'source' } + } + if (stage > 0) { + const writer = await requests.begin(admitted.owner!, binding) + const pages = stage === 2 ? 13 : prefix + for (const [sequence, table] of snapshotArchiveTables.slice(0, pages).entries()) { + await archives.append(writer, { sequence, table, rows: bytes[0], done: true, bytes }) + } + await expect(archives.inspect(identityKey, writer.archiveId)).rejects.toThrow('unavailable') + if (stage === 2) { + const manifest = await requests.seal(admitted.owner!, writer) + const recovered = await replacement.claim(identityKey, request) + expect(recovered).toEqual({ + receipt: { ...admitted.receipt, state: 'ready', archiveId: writer.archiveId, digest: manifest.digest } + }) + expect(manifest.rows).toBe(13 * bytes[0]) + expect(manifest.expiresAt).toBe(notAfter) + expect( + (await new KnexSnapshotArchiveStore(peer).read(identityKey, writer.archiveId, bytes[0] % 13)).bytes + ).toEqual(bytes) + } + } + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + await replacement.close(other, requestId) + await expect(replacement.status(other, requestId)).rejects.toThrow('unavailable') + expect((await db('snapshot_archive_capacity').first()).archives).toBe(1) + await replacement.close(identityKey, requestId, fail ? 'failed' : 'closed') + await requests.close(identityKey, requestId) + const terminal = (await requests.claim(identityKey, request)).receipt + expect(terminal.state).toBe(fail ? 'failed' : 'closed') + expect(terminal.archiveId).toBeUndefined() + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + const rows = await db('snapshot_archive_requests') + expect(rows).toHaveLength(1) + expect(Boolean(rows[0].released)).toBe(true) + // Each generated case starts with an independent empty request history. + await db('snapshot_archive_requests').delete() + } + ) + ) + } finally { + await Promise.all([db.destroy(), peer.destroy()]) + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveSql.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveSql.ts new file mode 100644 index 000000000..f4ff7478b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveSql.ts @@ -0,0 +1,28 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' + +export interface SnapshotArchiveCapacity { + archives: number + reservedBytes: number | string +} + +export async function snapshotArchiveDatabaseNow(k: Knex): Promise { + if (String(k.client.config.client).includes('mysql')) { + const [rows]: Array> = await k.raw( + 'SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000) AS now' + ) + return Number(rows[0].now) + } + const rows: Array<{ now: number }> = await k.raw( + "SELECT CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER) AS now" + ) + return rows[0].now +} + +export async function lockSnapshotArchiveCapacity(k: Knex): Promise { + // SQLite reserves the writer; MySQL locks the shared auxiliary capacity row. + await k('snapshot_archive_capacity').where({ id: 1 }).update({ id: 1 }) + const row: SnapshotArchiveCapacity | undefined = await k('snapshot_archive_capacity').where({ id: 1 }).first() + if (row === undefined) throw new WERR_INVALID_OPERATION('Snapshot archive schema is unavailable') + return row +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts index 7b90ccf94..975182041 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureKnexSnapshotArchive.ts @@ -1,46 +1,16 @@ import type { Knex } from 'knex' import type { Chain } from '../../../sdk/types' -import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' -import { runInSeries } from '../../../utility/runInSeries' +import { WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' import type { StorageKnex } from '../../StorageKnex' -import { encodeSyncTransfer } from '../../remoting/SyncTransfer' -import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' -import type { WalletSnapshotCursor, WalletSnapshotTable } from '../WalletReadSnapshot' -import { - KnexSnapshotArchiveStore, - snapshotArchiveLimits, - snapshotArchiveTables, - type SnapshotArchiveManifest, - type SnapshotArchiveWriter -} from './KnexSnapshotArchiveStore' +import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' +import type { SnapshotArchiveManifest } from './SnapshotArchive' import { openKnexSnapshotArchiveSource } from './KnexSnapshotArchiveSource' - -export interface SnapshotArchiveCaptureOptions { - signal?: AbortSignal - lifetimeMs?: number - maxBytes?: number - /** Acknowledged staging progress only; no source IDs, row contents or secrets. */ - onProgress?: (progress: { pages: number; rows: number; bytes: number }) => void -} - -function cancelled(signal: AbortSignal | undefined): void { - if (signal?.aborted === true) throw new WERR_INVALID_OPERATION('Snapshot archive capture was cancelled') -} - -function packedRows(rows: object[]): Array> { - return rows.map(row => - Object.fromEntries( - Object.entries(row).map(([key, value]) => { - if (value === null || typeof value !== 'object' || value instanceof Uint8Array) return [key, value] - // Retain SQL dates from other realms without asking the binary codec to - // classify their prototype. Standard wallet rows otherwise contain scalars. - const time = Date.prototype.getTime.call(value) - if (!Number.isFinite(time)) throw new WERR_INVALID_OPERATION('Snapshot source contains an invalid date') - return [key, new Date(time)] - }) - ) - ) -} +import { + captureSnapshotArchiveSource, + assertSnapshotArchiveCaptureActive, + type SnapshotArchiveCaptureOptions +} from './captureSnapshotArchiveSource' +export type { SnapshotArchiveCaptureOptions } from './captureSnapshotArchiveSource' /** * Capture all thirteen raw standard tables from one retained SQL view. Pages @@ -60,71 +30,14 @@ export async function captureKnexSnapshotArchive( if (chain !== 'main' && chain !== 'test') throw new WERR_INVALID_PARAMETER('chain', 'main or test') // Detach caller-owned options before the first asynchronous boundary. const { signal, lifetimeMs, maxBytes, onProgress } = options - cancelled(signal) + assertSnapshotArchiveCaptureActive(signal) const store = new KnexSnapshotArchiveStore(staging) await store.reap() const source = await openKnexSnapshotArchiveSource(reader, identityKey, { signal, lifetimeMs }) - let writer: SnapshotArchiveWriter | undefined - let closed = false - try { - if (source.user.identityKey !== identityKey || source.sourceStorage.chain !== chain) { - throw new WERR_INVALID_OPERATION('Snapshot source does not match the requested profile and chain') - } - writer = await store.begin( - { - version: 1, - snapshotId: source.snapshotId, - sourceStorage: source.sourceStorage, - sourceSchema: source.sourceSchema, - user: source.user - }, - { lifetimeMs, maxBytes } - ) - await source.validateClosure() - const owner = writer - const progress = { pages: 0, rows: 0, bytes: 0 } - const captureTable = async (table: WalletSnapshotTable): Promise => { - let cursor: WalletSnapshotCursor | undefined - let done = false - function* pendingPages(): Generator { - while (!done) yield undefined - } - await runInSeries(pendingPages(), async () => { - cancelled(signal) - if (progress.pages >= snapshotArchiveLimits.pages) { - throw new SnapshotResourceLimitError('Snapshot archive page limit exceeded') - } - // The conservative SQL charge bounds fetched payloads before encoding; - // framing/JSON escaping has a separate exact one-MiB admission below. - const page = await source.readPage(table, cursor, { maxRows: 128, maxBytes: 131072 }) - const bytes = encodeSyncTransfer({ version: 1, table, rows: packedRows(page.rows) }) - if (bytes.length > snapshotArchiveLimits.pageBytes) { - throw new SnapshotResourceLimitError('Snapshot archive encoded page limit exceeded') - } - cancelled(signal) - await store.append(owner, { sequence: progress.pages, table, rows: page.rows.length, done: page.done, bytes }) - progress.pages++ - progress.rows += page.rows.length - progress.bytes += bytes.length - onProgress?.({ ...progress }) - cancelled(signal) - cursor = page.cursor - done = page.done - }) - } - await runInSeries(snapshotArchiveTables, captureTable) - await source.close() - closed = true - cancelled(signal) - const manifest = await store.seal(owner) - cancelled(signal) - return manifest - } catch (error) { - // Preserve the initiating error; interrupted cleanup keeps its reservation - // and is recovered by reap rather than exposing a partial result. - if (writer !== undefined) await store.close(identityKey, writer.archiveId).catch(() => undefined) - throw error - } finally { - if (!closed) await source.close().catch(() => undefined) - } + return await captureSnapshotArchiveSource(source, store, identityKey, chain, { + signal, + lifetimeMs, + maxBytes, + onProgress + }) } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureSnapshotArchiveSource.ts new file mode 100644 index 000000000..a322823dd --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureSnapshotArchiveSource.ts @@ -0,0 +1,118 @@ +import type { Chain } from '../../../sdk/types' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { encodeSyncTransfer } from '../../remoting/SyncTransfer' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import type { WalletSnapshotCursor, WalletSnapshotTable } from '../WalletReadSnapshot' +import type { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' +import { + snapshotArchiveLimits, + snapshotArchiveTables, + type SnapshotArchiveManifest, + type SnapshotArchiveWriter +} from './SnapshotArchive' +import type { SnapshotArchiveSource } from './KnexSnapshotArchiveSource' + +export interface SnapshotArchiveCaptureOptions { + signal?: AbortSignal + lifetimeMs?: number + maxBytes?: number + /** Acknowledged staging progress only; no source IDs, row contents or secrets. */ + onProgress?: (progress: { pages: number; rows: number; bytes: number }) => void +} + +export function assertSnapshotArchiveCaptureActive(signal: AbortSignal | undefined): void { + if (signal?.aborted === true) throw new WERR_INVALID_OPERATION('Snapshot archive capture was cancelled') +} + +function packedRows(rows: object[]): Array> { + return rows.map(row => + Object.fromEntries( + Object.entries(row).map(([key, value]) => { + if (value === null || typeof value !== 'object' || value instanceof Uint8Array) return [key, value] + // Retain SQL dates from other realms without asking the binary codec to + // classify their prototype. Standard wallet rows otherwise contain scalars. + const time = Date.prototype.getTime.call(value) + if (!Number.isFinite(time)) throw new WERR_INVALID_OPERATION('Snapshot source contains an invalid date') + return [key, new Date(time)] + }) + ) + ) +} + +export type SnapshotArchiveCaptureStore = Pick + +/** Shared ordered capture; source.close must finish physical ownership cleanup before sealing. */ +export async function captureSnapshotArchiveSource( + source: SnapshotArchiveSource, + store: SnapshotArchiveCaptureStore, + identityKey: string, + chain: Chain, + options: SnapshotArchiveCaptureOptions = {} +): Promise { + const { signal, lifetimeMs, maxBytes, onProgress } = options + let writer: SnapshotArchiveWriter | undefined + let closed = false + try { + assertSnapshotArchiveCaptureActive(signal) + if (source.user.identityKey !== identityKey || source.sourceStorage.chain !== chain) { + throw new WERR_INVALID_OPERATION('Snapshot source does not match the requested profile and chain') + } + writer = await store.begin( + { + version: 1, + snapshotId: source.snapshotId, + sourceStorage: source.sourceStorage, + sourceSchema: source.sourceSchema, + user: source.user + }, + { lifetimeMs, maxBytes } + ) + await source.validateClosure() + const owner = writer + const progress = { pages: 0, rows: 0, bytes: 0 } + const captureTable = async (table: WalletSnapshotTable): Promise => { + let cursor: WalletSnapshotCursor | undefined + let done = false + function* pendingPages(): Generator { + while (!done) yield undefined + } + await runInSeries(pendingPages(), async () => { + assertSnapshotArchiveCaptureActive(signal) + if (progress.pages >= snapshotArchiveLimits.pages) { + throw new SnapshotResourceLimitError('Snapshot archive page limit exceeded') + } + // The conservative SQL charge bounds fetched payloads before encoding; + // framing/JSON escaping has a separate exact one-MiB admission below. + const page = await source.readPage(table, cursor, { maxRows: 128, maxBytes: 131072 }) + const bytes = encodeSyncTransfer({ version: 1, table, rows: packedRows(page.rows) }) + if (bytes.length > snapshotArchiveLimits.pageBytes) { + throw new SnapshotResourceLimitError('Snapshot archive encoded page limit exceeded') + } + assertSnapshotArchiveCaptureActive(signal) + await store.append(owner, { sequence: progress.pages, table, rows: page.rows.length, done: page.done, bytes }) + progress.pages++ + progress.rows += page.rows.length + progress.bytes += bytes.length + onProgress?.({ ...progress }) + assertSnapshotArchiveCaptureActive(signal) + cursor = page.cursor + done = page.done + }) + } + await runInSeries(snapshotArchiveTables, captureTable) + await source.close() + closed = true + assertSnapshotArchiveCaptureActive(signal) + const manifest = await store.seal(owner) + assertSnapshotArchiveCaptureActive(signal) + return manifest + } catch (error) { + // Preserve the initiating error; interrupted cleanup keeps its reservation + // and is recovered by reap rather than exposing a partial result. + if (writer !== undefined) await store.close(identityKey, writer.archiveId).catch(() => undefined) + throw error + } finally { + if (!closed) await source.close().catch(() => undefined) + } +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index abbd758dd..8e2e9cf5b 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -38,6 +38,16 @@ const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') const { captureKnexSnapshotArchive } = require('../../out/src/storage/snapshot/archive/captureKnexSnapshotArchive.js') const { decodeSyncTransfer } = require('../../out/src/storage/remoting/SyncTransfer.js') +const { KnexSnapshotArchiveService } = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveService.js') +const { + KnexSnapshotArchiveRequestStore +} = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.js') +const { snapshotArchiveRequestId } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveRequest.js') +const { snapshotArchiveDatabaseNow } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveSql.js') +const { + verifySnapshotArchiveDirectory, + verifySnapshotArchivePage +} = require('../../out/src/storage/snapshot/archive/SnapshotArchiveDirectory.js') const open = () => knex({ client: 'mysql2', connection, pool: { min: 1, max: 1 } }) const database = open(), replica = open(), @@ -133,7 +143,7 @@ async function captureFixture() { assert.equal(manifest.pages, 14) assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') assert.equal(manifest.binding.user.activeStorage, 'historical selection') - assert.equal(manifest.binding.sourceSchema, '2026-09-30-002 add snapshot archive staging') + assert.equal(manifest.binding.sourceSchema, '2026-09-30-003 add snapshot archive requests') const store = new KnexSnapshotArchiveStore(writer.knex) const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) @@ -149,6 +159,7 @@ async function captureFixture() { const proofPage = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 0)).bytes) assert.deepEqual(proofPage.rows[0].rawTx, new Uint8Array([1, 2, 255])) await store.close(identity, manifest.archiveId) + const requestLifecycle = await requestFixture(writer, reader) await writer.insertCommission({ created_at: date, updated_at: date, @@ -170,7 +181,8 @@ async function captureFixture() { originalPrimary: true, originalSchema: true, packedBinary: true, - crossProfileClosureRejected: true + crossProfileClosureRejected: true, + requestLifecycle } } finally { KnexSnapshotArchiveStore.prototype.append = originalAppend @@ -178,6 +190,100 @@ async function captureFixture() { await writer.destroy() } } +async function requestFixture(writer, reader) { + const controller = new KnexSnapshotArchiveService(writer) + const replacement = new KnexSnapshotArchiveService(reader) + const fields = { + version: 1, + nonce: 'b'.repeat(64), + notAfter: (await snapshotArchiveDatabaseNow(writer.knex)) + 300000, + maxBytes: 1048576 + } + const input = { ...fields, requestId: snapshotArchiveRequestId(fields) } + const originalOpen = writer.openSnapshotArchiveSource.bind(writer) + const originalAppend = KnexSnapshotArchiveStore.prototype.append + let claimedBeforePool = false + let recoveredWhileCapturing = false + writer.openSnapshotArchiveSource = async (...args) => { + assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).reservedBytes), fields.maxBytes) + claimedBeforePool = true + return await originalOpen(...args) + } + KnexSnapshotArchiveStore.prototype.append = async function (owner, page) { + await originalAppend.call(this, owner, page) + if (page.sequence === 0) { + const receipt = await replacement.create(identity, input) + assert.equal(receipt.state, 'building') + assert.equal(receipt.requestId, input.requestId) + recoveredWhileCapturing = true + await writer.knex('tx_labels').where({ label: 'replacement' }).update({ label: 'after-service-pin' }) + } + } + try { + const pending = controller.create(identity, input) + assert.equal(controller.create(identity, { ...input }), pending) + const ready = await pending + assert.equal(ready.state, 'ready') + assert.equal(ready.expiresAt, fields.notAfter) + assert.equal(claimedBeforePool, true) + assert.equal(recoveredWhileCapturing, true) + const directory = await replacement.directory(identity, ready.archiveId) + const verified = verifySnapshotArchiveDirectory(directory, { + identityKey: identity, + chain: 'test', + sourceStorageIdentityKey: 'native-source', + digest: ready.digest + }) + assert.equal(verified.manifest.binding.sourceSchema, '2026-09-30-003 add snapshot archive requests') + const page = await replacement.read(identity, ready.archiveId, 8) + const decoded = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[8])) + assert.equal(decoded.rows[0].label, 'replacement') + assert.equal(writer.snapshotSyncSource, undefined) + await controller.close() + assert.deepEqual(await replacement.create(identity, input), ready) + await assert.rejects(replacement.status(other, input.requestId), /unavailable/) + const requests = new KnexSnapshotArchiveRequestStore(writer.knex) + await Promise.all([replacement.cancel(identity, input.requestId), requests.close(identity, input.requestId)]) + assert.equal((await replacement.create(identity, input)).state, 'closed') + assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).reservedBytes), 0) + const nextFields = { ...fields, nonce: 'c'.repeat(64) } + const next = { ...nextFields, requestId: snapshotArchiveRequestId(nextFields) } + const admitted = await requests.claim(identity, next) + await writer.knex.raw( + "CREATE TRIGGER synthetic_request_assignment_failure BEFORE UPDATE ON snapshot_archive_requests FOR EACH ROW BEGIN IF NEW.archiveId IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic request assignment failure'; END IF; END" + ) + try { + await assert.rejects( + requests.begin(admitted.owner, verified.manifest.binding), + /synthetic request assignment failure/ + ) + } finally { + await writer.knex.raw('DROP TRIGGER synthetic_request_assignment_failure') + } + assert.equal( + (await writer.knex('snapshot_archive_requests').where({ requestId: next.requestId }).first()).state, + 'claimed' + ) + assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).reservedBytes), fields.maxBytes) + assert.equal((await writer.knex('snapshot_archives')).length, 0) + await requests.close(identity, next.requestId) + assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).archives), 0) + return { + claimedBeforePool, + recoveredWhileCapturing, + fixedDeadline: true, + verifiedDirectoryAndPage: true, + foregroundWrite: true, + replacementReadyReceipt: true, + concurrentClose: true, + atomicAssignmentRollback: true + } + } finally { + writer.openSnapshotArchiveSource = originalOpen + KnexSnapshotArchiveStore.prototype.append = originalAppend + await Promise.all([controller.close(), replacement.close()]) + } +} async function main() { try { const version = (await database.raw('SELECT VERSION() AS version'))[0][0].version diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 50be5c496..f0d3a7d85 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 39) + assert.equal(result.summary.propertySuites, 40) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 37) - assert.equal(result.summary.mutationTargets, 39) + assert.equal(result.summary.mutationTargets, 40) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index f4208f72a..8347a65d3 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -126,6 +126,18 @@ size boundaries and cross-connection SQL capture reads exercise this contract. This advances S3 table positioning without adding a remote endpoint, negotiating capabilities or establishing portable semantic/proof validation. +The durable request-lifecycle foundation reserves shared capacity before source +acquisition and atomically assigns an archive and publishes its ready receipt. +Its immutable deadline is part of request identity, so bounded receipt collection +does not allow an old request to reopen. A separate auxiliary migration retains +bounded terminal history and cleanup ownership. SQLite fault and generated +schedule tests cover these persistence rules. The local service controller now +integrates the existing owned source slot and shared capture path, reserves before +pool acquisition and drains cancellation/shutdown before releasing its admission. +Ready publication follows physical reader cleanup; failed cleanup fences the +controller, and completed archives survive replacement. Authenticated HTTP/client +integration and performance qualification under contention remain open. + These checkpoints advance parts of S1/S2/P1/S4. They do not complete primary reconciliation, indexed identity/update predicates and commit-order high-water positions, authenticated remote views, durable source views, streaming or staged From 8589574af5827a27fa886e5ac34c0bb1f76dd2cf Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 14:05:59 -0700 Subject: [PATCH 053/127] fix(infra): update compatible gRPC and IP lock resolutions Adopt the seven-lock correction from f8a3437517b2039662ac2de61942ad5ba9740553, independently qualified on PR 569. Preserve manifests, ranges, Axios selections, and the workspace lock. Clarify advisory and IP compatibility dates in the dependency record. --- docs/reference/dependency-policy.md | 23 +++++++++++++++++++ infra/chaintracks-server/package-lock.json | 12 +++++----- infra/message-box-server/package-lock.json | 6 ++--- infra/overlay-server/package-lock.json | 6 ++--- infra/uhrp-server-basic/package-lock.json | 6 ++--- .../package-lock.json | 6 ++--- infra/wab/package-lock.json | 6 ++--- infra/wallet-infra/package-lock.json | 12 +++++----- 8 files changed, 50 insertions(+), 27 deletions(-) diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index 2cf2c0b44..1c470b98a 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -165,6 +165,29 @@ correction needs no public npm version change or consumer migration. Hosted Linux image and exact-head analysis gates remain required before promotion; these local results do not establish deployed behavior. +The subsequent gRPC correction selects `@grpc/grpc-js` 1.14.5 in all seven +standalone service locks for the September 30 audit correction, addressing +[GHSA-m9gg-hp2v-232j](https://github.com/advisories/GHSA-m9gg-hp2v-232j) and +[GHSA-f596-whhp-79r4](https://github.com/advisories/GHSA-f596-whhp-79r4). +The reviewed [1.14.5 release](https://github.com/grpc/grpc-node/releases/tag/%40grpc/grpc-js%401.14.5) +preserves the Node engine and dependency ranges; its fixes also cover stale call +retention, status fields and completed HTTP/2 streams. Chaintracks Server and +Wallet Infra additionally adopt the already-reviewed `ip-address` 10.7.2 within +their existing ranges; the [10.7.2 release](https://github.com/beaugunderson/ip-address/releases/tag/v10.7.2) accepts case-insensitive ARPA suffixes. Package-manager regeneration changes only these nine +resolved nodes; manifests, lock formats, all other dependencies, the workspace +lock and the earlier Axios correction are unchanged. + +All seven frozen installs, allowlisted native rebuilds, builds and lints pass +on Node 24, with 658 service tests and seven zero-finding audits. Wallet Infra +has no standalone test script; its build/lint and the shared dependency checks +are reported separately. Each installed gRPC copy passes an ordinary loopback +unary call and client cancellation; both changed IP consumers pass IPv4/IPv6 +parsing and invalid-syntax checks. The gRPC registry's unpacked size grows by +49,814 bytes; no public workspace/browser/mobile graph changes. These are +compatibility checks, not a throughput or memory benchmark. No public npm +version or consumer migration changes; protected Linux image and exact-head +analysis gates still qualify the eventual service artifacts before promotion. + The root workspace carries six narrow audited dependency overrides: - Jest 30.4.2 still constrains parts of its reporting and coverage graph to diff --git a/infra/chaintracks-server/package-lock.json b/infra/chaintracks-server/package-lock.json index 0c0729836..6cee61a82 100644 --- a/infra/chaintracks-server/package-lock.json +++ b/infra/chaintracks-server/package-lock.json @@ -152,9 +152,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -3448,9 +3448,9 @@ } }, "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/infra/message-box-server/package-lock.json b/infra/message-box-server/package-lock.json index c151b1d3e..ba92cbc6b 100644 --- a/infra/message-box-server/package-lock.json +++ b/infra/message-box-server/package-lock.json @@ -1516,9 +1516,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", diff --git a/infra/overlay-server/package-lock.json b/infra/overlay-server/package-lock.json index fc8a4dc3b..793c7162f 100644 --- a/infra/overlay-server/package-lock.json +++ b/infra/overlay-server/package-lock.json @@ -679,9 +679,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", diff --git a/infra/uhrp-server-basic/package-lock.json b/infra/uhrp-server-basic/package-lock.json index b0e59006b..6d0a8c357 100644 --- a/infra/uhrp-server-basic/package-lock.json +++ b/infra/uhrp-server-basic/package-lock.json @@ -688,9 +688,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", diff --git a/infra/uhrp-server-cloud-bucket/package-lock.json b/infra/uhrp-server-cloud-bucket/package-lock.json index 6ddf71301..fd98b9586 100644 --- a/infra/uhrp-server-cloud-bucket/package-lock.json +++ b/infra/uhrp-server-cloud-bucket/package-lock.json @@ -926,9 +926,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", diff --git a/infra/wab/package-lock.json b/infra/wab/package-lock.json index 1feba8930..ae2439731 100644 --- a/infra/wab/package-lock.json +++ b/infra/wab/package-lock.json @@ -727,9 +727,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", diff --git a/infra/wallet-infra/package-lock.json b/infra/wallet-infra/package-lock.json index d54b11ff8..d66ce92f2 100644 --- a/infra/wallet-infra/package-lock.json +++ b/infra/wallet-infra/package-lock.json @@ -156,9 +156,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -3511,9 +3511,9 @@ } }, "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { "node": ">= 12" From ef0577eb53747df44bdfb5a8db4f9d1ca0c71dee Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 14:19:57 -0700 Subject: [PATCH 054/127] feat(wallet): add bounded authenticated snapshot archive transport Negotiate exact profile-bound RPCs, durable prompt admission and terminal resource-limit receipts. Verify source-bound directories and pages through bounded full/mobile transports. Fence and await capture and HTTP shutdown, preserving legacy RPC and reusable-server behavior. Remote row-reader and sync-manager adoption remain subsequent program work. --- docs/guides/wallet-sync-reliability.md | 66 ++++- docs/reference/package-api-migrations.md | 84 +++--- governance/mutation-testing/policy.json | 10 + governance/mutation-testing/targets.mjs | 91 +++++++ governance/package-release-notes.json | 12 +- governance/test-quality/policy.json | 13 + packages/wallet/wallet-toolbox/CHANGELOG.md | 10 +- packages/wallet/wallet-toolbox/README.md | 10 +- .../wallet/wallet-toolbox/client/README.md | 10 + .../wallet/wallet-toolbox/mobile/README.md | 10 + packages/wallet/wallet-toolbox/package.json | 2 +- .../src/storage/remoting/StorageClientBase.ts | 68 ++++- .../src/storage/remoting/StorageServer.ts | 91 ++++++- .../remoting/__test/StorageServerRpc.test.ts | 76 ++++++ .../KnexSnapshotArchiveRequestStore.ts | 16 +- .../archive/KnexSnapshotArchiveRpc.test.ts | 85 ++++++ .../archive/KnexSnapshotArchiveRpc.ts | 82 ++++++ .../KnexSnapshotArchiveService.test.ts | 122 ++++++++- .../archive/KnexSnapshotArchiveService.ts | 49 +++- .../archive/KnexSnapshotArchiveSource.ts | 4 +- .../SnapshotArchiveClientBoundary.test.ts | 257 ++++++++++++++++++ .../archive/SnapshotArchiveHttp.test.ts | 215 +++++++++++++++ .../SnapshotArchiveProtocol.property.test.ts | 73 +++++ .../archive/SnapshotArchiveProtocol.test.ts | 205 ++++++++++++++ .../archive/SnapshotArchiveProtocol.ts | 173 ++++++++++++ .../archive/SnapshotArchiveRequest.ts | 4 +- .../SnapshotArchiveServerLifecycle.test.ts | 94 +++++++ .../SnapshotArchiveService.property.test.ts | 8 +- .../archive/SnapshotArchiveTransport.test.ts | 98 +++++++ .../archive/SnapshotArchiveTransport.ts | 112 ++++++++ .../archive/captureSnapshotArchiveSource.ts | 7 +- .../test/utils/snapshotArchiveHttpFixtures.ts | 72 +++++ scripts/test-governance.test.mjs | 4 +- specs/wallet/sync-portability-program.md | 14 +- 34 files changed, 2139 insertions(+), 108 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveClientBoundary.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveServerLifecycle.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index a069a02d6..c9b40435d 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -390,15 +390,15 @@ binding private and give callers fresh metadata copies. The maximum accepted directory fits below a one-MiB metadata budget. Transport integration must separately limit incoming envelope bytes before parsing. These checks establish inclusion under the received root, not independent trust in the -source's data or proof semantics. The wire adapter, capability negotiation and -actual authenticated HTTP lifecycle remain incomplete. +source's data or proof semantics. The authenticated transport below consumes this directory; the remote row reader +and manager integration remain incomplete. The internal creation-request store adds the separate `2026-09-30-003 add snapshot archive requests` migration. A request ID hashes its version, nonce, immutable absolute deadline and byte reservation. Admission checks the database clock; the same expired request cannot reopen after its receipt has -been collected. A future client must obtain fresh authenticated server time -before selecting that deadline. This is not yet an exposed wire capability. +been collected. The authenticated transport obtains fresh server time before a caller selects +that deadline. Claiming a request reserves the existing shared handle/byte capacity before a source pool opens. Archive assignment and the handoff from that pending charge @@ -428,8 +428,8 @@ its reservation, including failure during source opening before a view is return The source view lasts at most five minutes or the request's remaining lifetime; the ready archive retains its original fixed deadline. This bounds process-local reader ownership, not distributed physical pools through arbitrary process loss -or unbounded driver cleanup. Authentication, capability negotiation and bounded -HTTP/client integration remain required before enabling remote snapshots. +or unbounded driver cleanup. The HTTP layer below exposes this controller; a +remote row reader and manager adoption remain separate work. The initial policy allows at most eight handles and 128 MiB of logical reserved storage globally, one handle and 32 MiB per profile, 1 MiB per page, 1,000 rows per @@ -461,8 +461,8 @@ rollback and partial DDL recovery. Its controller fixture captures thirteen tables over fourteen pages, retaining 140 original labels and primary metadata while an independent writer updates both. It also verifies schema provenance, packed binary and cross-profile relationship refusal. These results apply to -those fixtures. Deployed PXC, authenticated HTTP, canonical streaming, complete -portable semantic validation and the full #544 program remain open. +those fixtures. Deployed PXC, remote row/manager integration, canonical streaming, +complete portable semantic validation and the full #544 program remain open. Run the synthetic process-termination fixture from the repository root on macOS or Linux (Node 24 and the package build are required): @@ -572,3 +572,53 @@ and 56.8 MB paged. Inclusive boundary replay and the two-page settled unchanged copy remain intact. The same timing/sample limitations above apply; HTTP crypto and serialization still contribute event-loop delay. Subsequent forwarding and immutable-descriptor allocation refinements preserve the measured queue algorithm. + +## Authenticated snapshot archive transport (unpublished candidate) + +`StorageServer` now advertises a version-one `snapshotArchive` capability in its +runtime settings when the provider supports a dedicated static SQLite-WAL/MySQL +reader, the archive/request tables exist, and the configured envelopes can fit +the protocol. `snapshotSync: false` on the provider or `snapshotArchives: false` +on the server disables it. Persisted settings and ordinary RPC behavior remain +unchanged. Migration alone installs neither a listener nor a housekeeping worker. + +Both full and mobile clients expose `getSnapshotArchiveTransport(identityKey)` +after authenticated negotiation; old, disabled or unsupported peers return no +transport. A client can also set `snapshotArchives: false`. This is a low-level +archive transport. Ordinary remote sync and portable export do not yet adopt it. +It does not supply a `WalletReadSnapshot`, a remote destination, canonical BRC-38 +or independently validated transaction/proof semantics. + +The transport obtains a fresh authenticated database-clock offer carrying the +source storage identity, chain and schema. `start` acknowledges durable admission +promptly; `status` polls the same exact deadline-bound request. A disconnected +caller retries that tuple, without extending its deadline or opening a second +source. A replacement server recovers completed receipts/directories/pages from +shared storage. An interrupted incomplete capture cannot resume as a new view +under the old request. `cancel` closes that request; a new capture needs a new ID. + +Every method accepts exactly one versioned, profile-bound argument and rejects +extra ownership fields. The server binds the profile to BRC-103 authentication; +internal claim/writer tokens have no wire representation. The client retains its +shared authenticated server pin and separate storage-identity binding. Tagged +binary responses are mandatory. The dedicated AuthFetch caps response bytes at +2 MiB before framing/parsing, including the JSON-RPC envelope; maximum directory +and one-MiB-page fixtures fit that ceiling after HTML escaping and base64 encoding. +The server applies an additional 4-KiB request-envelope bound after its existing +bounded JSON parser. This is not a claim of a 4-KiB pre-parse allocation limit. + +Creation reaps expired pending, partial, ready and legacy archives before claiming +new capacity. Explicit `resource-limited` terminal receipts distinguish a failed +capture budget from other failures, preserving the first terminal state through +cleanup. Future sync integration may consider serialized fallback before it has +exposed a source or begun a destination session; corruption, authentication, +network failures and mid-stream errors must still fail the active operation. + +Server shutdown fences new captures synchronously and awaits both physical reader +cleanup and the HTTP close callback. A failed cleanup stays observable and keeps +its reservation. Temporary HTTP disconnection does not implicitly cancel an +accepted capture. Tests use real authenticated loopback HTTP with synthetic +SQLite profiles, both client variants, lost admission acknowledgements, server +replacement, profile isolation, rollback combinations, response limits and +shutdown during opening. These fixtures do not establish deployed performance, +physical mobile acceptance or completion of #544. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index d52ed675d..d58872b4e 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. These components do not enable an authenticated service; HTTP/client integration, capability negotiation and contention performance qualification remain incomplete. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. These components do not enable an authenticated service; HTTP/client integration, capability negotiation and contention performance qualification remain incomplete. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -537,8 +537,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -549,8 +549,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 0baea5406..a92e83ef3 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -127,6 +127,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-snapshot-remote-http", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts", + "risk": "critical", + "boundary": "Wallet authenticated snapshot HTTP negotiation, exact protocol fields, profile binding, immutable client receipts and bounded transport lifecycle", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "overlay-linkage", "manifest": "packages/overlays/topics/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 89b5c9543..f346ca17c 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -506,6 +506,97 @@ export function buildMutationTargets(repositoryRoot) { } ) }, + 'wallet-snapshot-remote-http': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts', + mutate: [ + 'src/storage/snapshot/archive/SnapshotArchiveProtocol.ts', + 'src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts', + 'src/storage/snapshot/archive/SnapshotArchiveTransport.ts', + ...[ + [ + 'src/storage/remoting/StorageClientBase.ts', + 'if (properties.snapshotArchive != null)', + 'return value as RemoteStorageSettings' + ], + [ + 'src/storage/remoting/StorageClientBase.ts', + 'this.snapshotWallet = wallet', + 'this.endpointUrl =' + ], + [ + 'src/storage/remoting/StorageClientBase.ts', + 'protected async authenticatedFetch(', + 'protected async traceRpcCall(' + ], + [ + 'src/storage/remoting/StorageClientBase.ts', + 'if (settings.snapshotArchive !== undefined', + 'this.settings = settings' + ], + [ + 'src/storage/remoting/StorageServer.ts', + 'private readonly snapshotArchivesEnabled:', + 'private readonly app =' + ], + [ + 'src/storage/remoting/StorageServer.ts', + 'this.snapshotArchivesEnabled =', + '// Keep legacy configurations working' + ], + [ + 'src/storage/remoting/StorageServer.ts', + 'private async handleRpcRequestCore(', + 'private async sendOversizedSyncResponse(' + ], + [ + 'src/storage/remoting/StorageServer.ts', + 'const snapshotArchive = await', + 'this.finishRpcLogging(logger, result)' + ], + [ + 'src/storage/remoting/StorageServer.ts', + 'private createSnapshotArchiveRpc(', + 'private async traceRpcStep(' + ], + ['src/storage/remoting/StorageServer.ts', 'public start(): void', 'validateDate(date:'] + ].map(([filePath, startMarker, endMarker]) => + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + filePath, + startMarker, + endMarker + ) + ) + ], + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/snapshot/archive/SnapshotArchive*.test.ts', + '/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts', + '/src/storage/remoting/__test/BinaryJson.test.ts', + '/src/storage/remoting/__test/KnexPaymentReplayStore.test.ts', + '/src/storage/remoting/__test/KnexSessionManager.test.ts', + '/src/storage/remoting/__test/RateLimitPolicy.test.ts', + '/src/storage/remoting/__test/StorageServerRpc.test.ts', + '/src/storage/remoting/__test/StorageClientBase.*.test.ts', + '/src/storage/remoting/__test/StorageClient.security.test.ts', + '/src/storage/remoting/__test/StorageClient.transport.security.test.ts', + '/src/storage/remoting/__test/StorageClient.telemetry.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + }, ...snapshotSyncMutationTargets(repositoryRoot), 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 60c2f6ef7..b836dfca4 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. This component does not yet expose an authenticated remote snapshot/export API. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation, authenticated integration and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. These components do not enable an authenticated service; HTTP/client integration, capability negotiation and contention performance qualification remain incomplete." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544.", + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change." + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544.", + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved." }, { "name": "create-bsv-app", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index d9b7447c1..feb875e1a 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -548,6 +548,19 @@ "Archive assignment and ready receipt publication commit atomically.", "Terminal requests retain bounded receipts and release physical capacity exactly once." ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet authenticated snapshot HTTP negotiation, exact protocol fields, profile binding, immutable client receipts and bounded transport lifecycle", + "target": "Generated deadline-bound requests, exact RPC shapes and client receipt validation", + "invariants": [ + "Requests and receipts retain the original immutable identity and deadline across start/status retries.", + "Every RPC accepts only its exact typed fields and never an internal ownership token.", + "Ready receipts bind one archive/root while every distinct terminal state remains explicit.", + "Unexpected identity, expiry or ownership fields are rejected rather than silently normalized." + ] } ], "exclusions": [ diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index ead455e29..faddb44a9 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -13,7 +13,15 @@ attention to changes that materially alter behavior or extend functionality. The local capture controller binds source metadata/schema to one read view, verifies profile relationships and stores all thirteen raw tables through bounded binary frames, with cancellation and cleanup. Canonical portable - validation, authenticated endpoints and larger-wallet policy remain open. + validation and larger-wallet policy remain open. + +- Add negotiated authenticated archive admission/status/directory/page/cancel + methods for migrated static WAL/MySQL sources, with exact profile-bound + arguments and no writer credentials. Both client variants enforce a separate + two-MiB response ceiling and retain server/storage identity binding. Expired + request cleanup precedes admission; resource-limit receipts are explicit. + Shutdown awaits physical capture and HTTP cleanup. Remote row-reader/manager + integration, portable semantics and the full program remain incomplete. - Integrate coherent SQL pages into ordinary local push, pull and backup, with a dedicated source reader and short, fair destination commits. Add resumable diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index d61e764c2..12632bd45 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -61,9 +61,13 @@ and archive, reserves capacity before source acquisition, and retains bounded terminal receipts through cleanup. Its internal capture controller shares the provider's single owned reader slot, publishes readiness after physical cleanup, and drains cancellation/shutdown. Completed archives survive controller replacement; -failed cleanup fences admission and retains its reservation. Authenticated HTTP, -capability negotiation and bounded client integration remain incomplete; this does -not enable remote snapshots. +failed cleanup fences admission and retains its reservation. The candidate now +adds a negotiated authenticated archive transport on migrated WAL/MySQL servers +and both client variants, with bounded responses and durable admission/status. +`getSnapshotArchiveTransport(identityKey)` is a low-level API; remote row reading, +ordinary sync/export adoption and portable validation remain incomplete. Server +or client `snapshotArchives: false`, or provider `snapshotSync: false`, disables +this capability. See the [transport contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#authenticated-snapshot-archive-transport-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/client/README.md b/packages/wallet/wallet-toolbox/client/README.md index 04f29e219..47bd1fe19 100644 --- a/packages/wallet/wallet-toolbox/client/README.md +++ b/packages/wallet/wallet-toolbox/client/README.md @@ -241,3 +241,13 @@ push progress/cancellation with an explicit exclusive fallback. These exports support adapter integration; they do not enable SQL retention or the auxiliary SQL migration in this browser/mobile entry point. Current IndexedDB and remote paths retain their documented behavior. See the [local SQL integration and remaining limits](../../../../docs/guides/wallet-sync-reliability.md#durable-local-sql-sync-and-ordinary-backup). + +The candidate now includes `getSnapshotArchiveTransport(identityKey)` on +`StorageClient`. It negotiates a migrated WAL/MySQL server's immutable archive +capability and validates bounded authenticated admission/status, directories and +pages. A dedicated response ceiling applies before parsing; server and storage +identity bindings remain shared with ordinary RPC. `snapshotArchives: false` +disables the transport, and old/disabled peers decline it. This is a low-level +transport, not a retained database adapter or adoption by ordinary sync/export. +Remote row cursors, manager integration, portable semantics and physical platform +acceptance remain open. See the [archive transport contract](../../../../docs/guides/wallet-sync-reliability.md#authenticated-snapshot-archive-transport-unpublished-candidate). diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index d4c74fa5d..1ab5744ac 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -277,3 +277,13 @@ push progress/cancellation with an explicit exclusive fallback. These exports support adapter integration; they do not enable SQL retention or the auxiliary SQL migration in this browser/mobile entry point. Current IndexedDB and remote paths retain their documented behavior. See the [local SQL integration and remaining limits](../../../../docs/guides/wallet-sync-reliability.md#durable-local-sql-sync-and-ordinary-backup). + +The candidate now includes `getSnapshotArchiveTransport(identityKey)` on +`StorageClient`. It negotiates a migrated WAL/MySQL server's immutable archive +capability and validates bounded authenticated admission/status, directories and +pages. A dedicated response ceiling applies before parsing; server and storage +identity bindings remain shared with ordinary RPC. `snapshotArchives: false` +disables the transport, and old/disabled peers decline it. This is a low-level +transport, not a retained database adapter or adoption by ordinary sync/export. +Remote row cursors, manager integration, portable semantics and physical platform +acceptance remain open. See the [archive transport contract](../../../../docs/guides/wallet-sync-reliability.md#authenticated-snapshot-archive-transport-unpublished-candidate). diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 038ca5145..31f24c063 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index fc14c34ce..38f6164db 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -91,6 +91,16 @@ import { supportedActionBatchPackEncodings } from '../../utility/actionBatchPack' import { pruneBeefForTxids } from '../../utility/beefForTxids' +import { SnapshotArchiveTransport } from '../snapshot/archive/SnapshotArchiveTransport' +import { + snapshotArchiveResponseBytes, + snapshotArchiveRequestBytes, + validateSnapshotArchiveCapabilities, + type SnapshotArchiveCapabilities, + type SnapshotArchiveMethod +} from '../snapshot/archive/SnapshotArchiveProtocol' +import { BINARY_ENCODING, BINARY_ENCODING_HEADER, parseJsonRpc, validateJsonRpcResponse } from './BinaryJson' +import { WalletErrorFromJson } from '../../sdk/WalletErrorFromJson' const syncChunkResponseRetryLimit = 4 const minimumSyncChunkRoughSize = 64 * 1024 @@ -103,9 +113,12 @@ type RemoteStorageSettings = TableSettings & { /** Runtime-only RPC advertisement, not a persisted settings-table column. */ syncCheckpointVersion?: 1 syncTransfer?: SyncTransferCapabilities + snapshotArchive?: SnapshotArchiveCapabilities } export interface StorageClientOptions { + /** Disable immutable remote snapshots during a mixed-version rollout. */ + snapshotArchives?: boolean /** * Send compact tagged binary request values after the server advertises * support. Leave disabled during rolling deployments where an endpoint may @@ -250,6 +263,7 @@ function validateRemoteStorageSettings(value: unknown): RemoteStorageSettings { if (properties.syncCheckpointVersion != null && properties.syncCheckpointVersion.value !== 1) { throw new Error('Wallet storage returned invalid settings.') } + if (properties.snapshotArchive != null) validateSnapshotArchiveCapabilities(properties.snapshotArchive.value) return value as RemoteStorageSettings } @@ -270,6 +284,10 @@ export abstract class StorageClientBase implements WalletStorageProvider { private authenticatedServerIdentityKey?: string private readonly expectedStorageIdentityKey?: string private syncChunkRoughSizeLimit?: number + private readonly snapshotArchivesEnabled: boolean + private readonly snapshotWallet: WalletInterface + private snapshotAuthClient?: AuthFetch + private snapshotSource?: { identityKey: string; chain: 'main' | 'test' } /** Optional progress/cancellation hook for a bounded transfer; never receives wallet contents. */ onSyncTransferProgress?: (progress: { direction: 'read' | 'write'; bytes: number; totalBytes: number }) => void @@ -278,6 +296,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { constructor(wallet: WalletInterface, endpointUrl: string, options: StorageClientOptions = {}) { this.authClient = new AuthFetch(wallet) + this.snapshotWallet = wallet + this.snapshotArchivesEnabled = options.snapshotArchives !== false this.endpointUrl = normalizeStorageEndpointUrl(endpointUrl) this.binaryRequests = options.binaryRequests === true this.telemetry = new Telemetry(options.telemetry) @@ -288,7 +308,10 @@ export abstract class StorageClientBase implements WalletStorageProvider { } protected async authenticatedFetch(url: string, config: Parameters[1]): Promise { - const response = await this.authClient.fetch(url, config) + return this.validateAuthenticatedResponse(await this.authClient.fetch(url, config)) + } + + private validateAuthenticatedResponse(response: Response): Response { const authenticatedIdentityKey = response.headers.get('x-bsv-auth-identity-key') if (authenticatedIdentityKey == null) { throw new Error('Wallet storage response was not mutually authenticated.') @@ -301,6 +324,46 @@ export abstract class StorageClientBase implements WalletStorageProvider { return response } + /** Available only after an authenticated compatible advertisement. */ + async getSnapshotArchiveTransport(identityKey: string): Promise { + await this.makeAvailable() + if (!this.snapshotArchivesEnabled || this.snapshotSource === undefined) return undefined + return new SnapshotArchiveTransport( + (method, params, signal) => this.snapshotRpcCall(method, params, signal), + identityKey, + this.snapshotSource.identityKey, + this.snapshotSource.chain + ) + } + + private async snapshotRpcCall( + method: SnapshotArchiveMethod, + params: unknown[], + signal?: AbortSignal + ): Promise { + const id = this.nextRequestId() + const body = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + if (new TextEncoder().encode(body).length > snapshotArchiveRequestBytes) + throw new TypeError('Snapshot archive request exceeds its transport limit') + this.snapshotAuthClient ??= new AuthFetch(this.snapshotWallet, undefined, undefined, undefined, { + maxResponseBytes: snapshotArchiveResponseBytes + }) + const response = this.validateAuthenticatedResponse( + await this.snapshotAuthClient.fetch(this.endpointUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [BINARY_ENCODING_HEADER]: BINARY_ENCODING }, + body, + signal + }) + ) + if (!response.ok) throw this.rpcResponseError(response) + if (response.headers.get(BINARY_ENCODING_HEADER) !== BINARY_ENCODING) + throw new TypeError('Snapshot archive requires compact binary responses') + const json = validateJsonRpcResponse(parseJsonRpc(await response.text(), true), id) + if ('error' in json) throw WalletErrorFromJson(json.error as object) + return json.result + } + protected async traceRpcCall( method: string, params: unknown[], @@ -407,6 +470,9 @@ export abstract class StorageClientBase implements WalletStorageProvider { if (this.expectedStorageIdentityKey !== undefined && storageIdentityKey !== this.expectedStorageIdentityKey) { throw new Error('Wallet storage settings identity does not match the configured storage identity.') } + if (settings.snapshotArchive !== undefined && (settings.chain === 'main' || settings.chain === 'test')) { + this.snapshotSource = { identityKey: storageIdentityKey, chain: settings.chain } + } this.settings = settings return this.settings } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts index 8ff0ceeb8..e556c6301 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts @@ -67,6 +67,14 @@ import { } from '../../utility/actionBatchPack' import { ACTION_BATCH_MAX_PACK_BYTES, ACTION_BATCH_MAX_PACK_ITEMS } from '../methods/actionBatchBlobs' import { validateSyncProofs } from './validateRpcSyncProofs' +import { KnexSnapshotArchiveRpc } from '../snapshot/archive/KnexSnapshotArchiveRpc' +import { + snapshotArchiveMethods, + snapshotArchiveRequestBytes, + snapshotArchiveResponseBytes, + type SnapshotArchiveMethod +} from '../snapshot/archive/SnapshotArchiveProtocol' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' const storageRpcMethods = new Set([ 'abortAction', @@ -270,12 +278,18 @@ export interface WalletStorageServerOptions { maxRpcResponseBytes?: number /** Disable the additive durable transfer transport during a mixed-version rollout. Knex only. */ syncTransfers?: boolean + /** Disable immutable snapshot archives during a mixed-version rollout. Requires migrated WAL/MySQL storage and snapshotSync. */ + snapshotArchives?: boolean /** Durable BRC-105 replay claims for monetized multi-replica deployments. */ paymentReplayStore?: PaymentReplayStore } export class StorageServer { private readonly syncTransfers?: KnexSyncTransferStore + private readonly snapshotArchivesEnabled: boolean + private snapshotArchives?: KnexSnapshotArchiveRpc + private closing?: Promise + private closed = false private readonly app = express() private readonly host?: string private readonly port: number @@ -396,6 +410,11 @@ export class StorageServer { highThroughput: 32 * 1024 * 1024 }) ) + this.snapshotArchivesEnabled = + options.snapshotArchives !== false && + jsonBodyLimit >= snapshotArchiveRequestBytes && + (this.maxRpcResponseBytes === -1 || this.maxRpcResponseBytes >= snapshotArchiveResponseBytes) + this.snapshotArchives = this.createSnapshotArchiveRpc() // Keep legacy configurations working when their envelopes cannot fit even a minimum part. if ( options.syncTransfers !== false && @@ -640,8 +659,9 @@ export class StorageServer { const logObj = this.createRpcLog(req, method, id, params) try { this.enforceRpcRequestBudgets(method, params) - const dispatch = - method.endsWith('SyncTransfer') || method.endsWith('SyncTransferPart') + const dispatch = snapshotArchiveMethods.includes(method as SnapshotArchiveMethod) + ? await this.dispatchSnapshotArchive(method as SnapshotArchiveMethod, params, req, useBinary, id) + : method.endsWith('SyncTransfer') || method.endsWith('SyncTransferPart') ? await this.dispatchSyncTransfer(method, params, req) : await this.dispatchRpcCall(method, params, req, logObj, rpcSpan) if (!dispatch.found) { @@ -663,6 +683,12 @@ export class StorageServer { // JSON.stringify silently omit an undefined result. const payload = { jsonrpc: '2.0', result: result ?? null, id } const serialized = escapeRpcJson(stringifyJsonRpc(payload, useBinary)) + if ( + snapshotArchiveMethods.includes(method as SnapshotArchiveMethod) && + Buffer.byteLength(serialized, 'utf8') > snapshotArchiveResponseBytes + ) { + throw new WERR_INVALID_OPERATION('Snapshot archive response exceeds its transport limit') + } // Apply the response bound before consulting any client transport preference. if (this.maxRpcResponseBytes !== -1 && Buffer.byteLength(serialized, 'utf8') > this.maxRpcResponseBytes) { return await this.sendOversizedSyncResponse(req, res, useBinary, method, params, payload) @@ -934,6 +960,8 @@ export class StorageServer { syncCheckpointVersion: 1, ...(this.syncTransfers == null ? {} : { syncTransfer: this.syncTransfers.capabilities }) } + const snapshotArchive = await this.snapshotArchives?.capabilities() + if (snapshotArchive !== undefined) result.snapshotArchive = snapshotArchive } this.finishRpcLogging(logger, result) return { found: true, result } @@ -1011,6 +1039,36 @@ export class StorageServer { return { found: true, result } } + private createSnapshotArchiveRpc(): KnexSnapshotArchiveRpc | undefined { + return this.snapshotArchivesEnabled && this.storage instanceof StorageKnex + ? new KnexSnapshotArchiveRpc(this.storage) + : undefined + } + + private async dispatchSnapshotArchive( + method: SnapshotArchiveMethod, + params: unknown[], + req: Request, + useBinary: boolean, + id: unknown + ): Promise { + if (this.snapshotArchives === undefined) return { found: false } + // The shared JSON parser already bounds ingress. This smaller post-parse + // envelope check is an additional protocol bound, not a pre-parse claim. + if ( + !useBinary || + !Number.isSafeInteger(id) || + Number(id) < 1 || + Buffer.byteLength(JSON.stringify(req.body), 'utf8') > snapshotArchiveRequestBytes + ) { + throw new WERR_INVALID_OPERATION('Snapshot archive requests require bounded compact-binary RPC framing') + } + return { + found: true, + result: await this.snapshotArchives.dispatch(method, params, requiredAuthenticatedIdentityKey(req)) + } + } + private async traceRpcStep( name: string, parent: TelemetrySpan | undefined, @@ -1165,6 +1223,12 @@ export class StorageServer { server: any public start(): void { + if (this.closing !== undefined) { + if (!this.closed) throw new WERR_INVALID_OPERATION('Storage server is closing') + this.closing = undefined + this.closed = false + this.snapshotArchives = this.createSnapshotArchiveRpc() + } const listening = (): void => { console.log(`WalletStorageServer listening at http://${this.host ?? 'localhost'}:${this.port}`) } @@ -1173,12 +1237,25 @@ export class StorageServer { configureHttpServer(this.server, 'WALLET_STORAGE', this.httpPolicy) } - public async close(): Promise { - if (this.server) { - await this.server.close(() => { - // console.log('WalletStorageServer closed') + public close(): Promise { + if (this.closing !== undefined) return this.closing + // Fence capture admission synchronously, then await BOTH physical drains. + const captures = this.snapshotArchives?.close() ?? Promise.resolve() + const http = new Promise((resolve, reject) => { + if (!this.server) { + resolve() + return + } + this.server.close((error?: NodeJS.ErrnoException) => { + if (error !== undefined && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error) + else resolve() }) - } + }) + this.closing = Promise.allSettled([captures, http]).then(results => { + for (const result of results) if (result.status === 'rejected') throw result.reason + this.closed = true + }) + return this.closing } /** @see {@link validateDate} */ diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts index 37c44ea5d..5e68a4ffd 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts @@ -944,3 +944,79 @@ describe('StorageServer JSON-RPC boundary', () => { ).rejects.toThrow('identityKey does not match authentication') }) }) + +describe('shared RPC framing used by snapshot transport', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => undefined) + jest.spyOn(console, 'error').mockImplementation(() => undefined) + }) + afterEach(() => jest.restoreAllMocks()) + + test.each([{ jsonrpc: '1.0' }, { method: '' }, { method: 123 }, { params: {} }])( + 'rejects a malformed envelope at the RPC boundary: %p', + async changes => { + const server = makeServer() + const captured = makeResponse() + await invoke( + server, + 'handleRpcRequest', + makeRequest({ jsonrpc: '2.0', method: 'getSettings', params: [], id: 1, ...changes }), + captured.response + ) + expect(captured.statusCode).toBe(400) + expect(captured.body).toEqual({ error: { code: -32600, message: 'Invalid Request' } }) + expect(captured.headers[BINARY_ENCODING_HEADER]).toBeUndefined() + } + ) + + test.each([false, true])('decodes request bytes only with explicit negotiation: %p', async binary => { + const server = makeServer() + const dispatch = jest + .spyOn(server as never, 'dispatchRpcCall' as never) + .mockResolvedValue({ found: true, result: null } as never) + const captured = makeResponse() + const tagged = { $bsvBinary: 'base64', data: 'AQI=' } + await invoke( + server, + 'handleRpcRequest', + makeRequest( + { jsonrpc: '2.0', method: 'getSettings', params: [tagged], id: 1 }, + binary ? { [BINARY_REQUEST_ENCODING_HEADER]: BINARY_ENCODING } : {} + ), + captured.response + ) + expect(dispatch.mock.calls[0][1]).toEqual(binary ? [new Uint8Array([1, 2])] : [tagged]) + expect(captured.body).toEqual({ jsonrpc: '2.0', id: 1, result: null }) + }) + + test.each(['exact', 'over', 'unlimited'] as const)( + 'response limits preserve transfer negotiation at %s', + async mode => { + const chunk = { ...emptyChunk, padding: 'x'.repeat(1024) } + const bytes = Buffer.byteLength(JSON.stringify({ jsonrpc: '2.0', result: chunk, id: 1 })) + const maximum = mode === 'unlimited' ? -1 : bytes - Number(mode === 'over') + const server = makeServer({ getSyncChunk: async () => chunk }, { maxRpcResponseBytes: maximum }) + const beginRead = jest.fn(async () => ({ transferId: 'fixture-transfer' })) + Reflect.set(server, 'syncTransfers', { beginRead }) + const captured = makeResponse() + await invoke( + server, + 'handleRpcRequest', + makeRequest({ + jsonrpc: '2.0', + method: 'getSyncChunk', + params: [{ identityKey: 'alice', syncTransferVersion: 1 }], + id: 1 + }), + captured.response + ) + expect(captured.statusCode).toBe(200) + expect(beginRead).toHaveBeenCalledTimes(Number(mode === 'over')) + expect(captured.body).toEqual({ + jsonrpc: '2.0', + id: 1, + result: mode === 'over' ? { syncTransfer: { transferId: 'fixture-transfer' } } : chunk + }) + } + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts index ce42a0ebd..1b328566f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts @@ -15,14 +15,15 @@ import { parseSnapshotArchiveRequest, validateSnapshotArchiveRequest, type SnapshotArchiveRequestOwner, - type SnapshotArchiveRequestReceipt + type SnapshotArchiveRequestReceipt, + type SnapshotArchiveTerminalState } from './SnapshotArchiveRequest' interface RequestRow { identityKey: string requestId: string claimToken: string - state: 'claimed' | 'capturing' | 'ready' | 'closed' | 'failed' | 'expired' + state: 'claimed' | 'capturing' | 'ready' | SnapshotArchiveTerminalState requestJson: string expiresAt: number | string reservedBytes: number | string @@ -169,11 +170,7 @@ export class KnexSnapshotArchiveRequestStore { }) } - async close( - identityKey: string, - requestId: string, - state: 'closed' | 'failed' | 'expired' = 'closed' - ): Promise { + async close(identityKey: string, requestId: string, state: SnapshotArchiveTerminalState = 'closed'): Promise { identity(identityKey) identifier(requestId) await this.knex.transaction(async trx => { @@ -190,7 +187,8 @@ export class KnexSnapshotArchiveRequestStore { const archiveId = await this.knex.transaction(async trx => { const capacity = await lockSnapshotArchiveCapacity(trx) const row: RequestRow | undefined = await trx(table).where({ identityKey, requestId }).first() - if (row === undefined || row.released || !['closed', 'failed', 'expired'].includes(row.state)) return undefined + if (row === undefined || row.released || !['closed', 'failed', 'expired', 'resource-limited'].includes(row.state)) + return undefined if (row.archiveId !== null) return row.archiveId await trx('snapshot_archive_capacity') .where({ id: 1 }) @@ -213,7 +211,7 @@ export class KnexSnapshotArchiveRequestStore { const now = await snapshotArchiveDatabaseNow(this.knex) const rows: RequestRow[] = await this.knex(table) .where(query => { - void query.where('expiresAt', '<=', now).orWhereIn('state', ['closed', 'failed', 'expired']) + void query.where('expiresAt', '<=', now).orWhereIn('state', ['closed', 'failed', 'expired', 'resource-limited']) }) .limit(snapshotArchiveRequestLimits.total) await runInSeries(rows, async row => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts new file mode 100644 index 000000000..8b5e7b001 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts @@ -0,0 +1,85 @@ +import { KnexSnapshotArchiveRpc } from './KnexSnapshotArchiveRpc' +import { snapshotArchiveCapabilities } from './SnapshotArchiveProtocol' +import { StorageKnex } from '../../StorageKnex' +import { gate, snapshotHttpFixture } from '../../../../test/utils/snapshotArchiveHttpFixtures' + +afterEach(() => jest.restoreAllMocks()) + +test.each(['snapshot_archive_requests', 'snapshot_archives', 'snapshot_archive_pages', 'snapshot_archive_capacity'])( + 'declines capability when migration table %s is missing', + async table => { + const fixture = await snapshotHttpFixture() + const rpc = new KnexSnapshotArchiveRpc(fixture.storage) + try { + expect(await rpc.capabilities()).toEqual(snapshotArchiveCapabilities) + await fixture.storage.knex.schema.dropTable(table) + expect(await rpc.capabilities()).toBeUndefined() + await expect( + rpc.dispatch('getSnapshotArchiveOffer', [{ version: 1, identityKey: fixture.identityKey }], fixture.identityKey) + ).rejects.toThrow('unavailable') + } finally { + await rpc.close() + await fixture.close() + } + } +) + +test('unsupported source and a close during capability probing never advertise or open a pool', async () => { + const fixture = await snapshotHttpFixture() + const rpc = new KnexSnapshotArchiveRpc(fixture.storage) + try { + const open = jest.spyOn(fixture.storage, 'openSnapshotArchiveSource') + const supported = jest.spyOn(fixture.storage, 'supportsSnapshotArchiveSource').mockResolvedValue(false) + expect(await rpc.capabilities()).toBeUndefined() + const entered = gate() + const resume = gate() + supported.mockImplementation(async () => { + entered.resolve() + await resume.promise + return true + }) + const pending = rpc.capabilities() + await entered.promise + await rpc.close() + resume.resolve() + expect(await pending).toBeUndefined() + expect(await rpc.capabilities()).toBeUndefined() + expect(open).not.toHaveBeenCalled() + } finally { + await rpc.close() + await fixture.close() + } +}) + +test('unsupported chain is declined and remains checked before an offer', async () => { + const storage = { chain: 'mock', knex: {} } as unknown as StorageKnex + const rpc = new KnexSnapshotArchiveRpc(storage) + expect(await rpc.capabilities()).toBeUndefined() + jest.spyOn(rpc, 'capabilities').mockResolvedValue(snapshotArchiveCapabilities) + const identityKey = '02' + '11'.repeat(32) + await expect(rpc.dispatch('getSnapshotArchiveOffer', [{ version: 1, identityKey }], identityKey)).rejects.toThrow( + 'chain is unavailable' + ) + await rpc.close() +}) + +test('main-chain sources negotiate and return a main-chain offer without opening a capture', async () => { + const fixture = await snapshotHttpFixture() + Reflect.set(fixture.storage, 'chain', 'main') + const rpc = new KnexSnapshotArchiveRpc(fixture.storage) + try { + const open = jest.spyOn(fixture.storage, 'openSnapshotArchiveSource') + expect(await rpc.capabilities()).toEqual(snapshotArchiveCapabilities) + expect( + await rpc.dispatch( + 'getSnapshotArchiveOffer', + [{ version: 1, identityKey: fixture.identityKey }], + fixture.identityKey + ) + ).toMatchObject({ chain: 'main', sourceStorageIdentityKey: 'http-snapshot-source' }) + expect(open).not.toHaveBeenCalled() + } finally { + await rpc.close() + await fixture.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts new file mode 100644 index 000000000..1c95e34e1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts @@ -0,0 +1,82 @@ +import { WERR_INVALID_OPERATION, WERR_UNAUTHORIZED } from '../../../sdk/WERR_errors' +import type { StorageKnex } from '../../StorageKnex' +import { KnexSnapshotArchiveService } from './KnexSnapshotArchiveService' +import { readSnapshotArchiveSourceSchema } from './KnexSnapshotArchiveSource' +import { snapshotArchiveDatabaseNow } from './SnapshotArchiveSql' +import { + parseSnapshotArchiveRpcInput, + snapshotArchiveCapabilities, + type SnapshotArchiveMethod, + type SnapshotArchiveOffer +} from './SnapshotArchiveProtocol' + +/** Authenticated dispatch. The HTTP edge still owns framing, rate and body limits. */ +export class KnexSnapshotArchiveRpc { + private readonly service: KnexSnapshotArchiveService + private stopped = false + + constructor(private readonly storage: StorageKnex) { + this.service = new KnexSnapshotArchiveService(storage) + } + + async capabilities() { + if ( + this.stopped || + (this.storage.chain !== 'main' && this.storage.chain !== 'test') || + this.storage.getSnapshotSync() === undefined || + !(await this.storage.supportsSnapshotArchiveSource()) + ) + return undefined + for (const name of [ + 'snapshot_archive_requests', + 'snapshot_archives', + 'snapshot_archive_pages', + 'snapshot_archive_capacity' + ]) { + if (!(await this.storage.knex.schema.hasTable(name))) return undefined + } + return this.stopped ? undefined : snapshotArchiveCapabilities + } + + async dispatch(method: SnapshotArchiveMethod, params: unknown[], authenticatedIdentityKey: string): Promise { + const input = parseSnapshotArchiveRpcInput(method, params) + if (input.identityKey !== authenticatedIdentityKey) + throw new WERR_UNAUTHORIZED('Snapshot archive identity must match authentication') + if ((await this.capabilities()) === undefined) + throw new WERR_INVALID_OPERATION('Snapshot archive transport is unavailable') + const identityKey = authenticatedIdentityKey + switch (input.method) { + case 'getSnapshotArchiveOffer': { + const chain = this.storage.chain + if (chain !== 'main' && chain !== 'test') + throw new WERR_INVALID_OPERATION('Snapshot archive chain is unavailable') + const sourceSchema = await readSnapshotArchiveSourceSchema(this.storage, this.storage.knex) + const settings = this.storage.getSettings() + const result: SnapshotArchiveOffer = { + version: 1, + sourceStorageIdentityKey: settings.storageIdentityKey, + sourceSchema, + chain, + serverTime: await snapshotArchiveDatabaseNow(this.storage.knex) + } + return result + } + case 'startSnapshotArchive': + return await this.service.start(identityKey, input.request) + case 'getSnapshotArchiveStatus': + return await this.service.status(identityKey, input.requestId) + case 'getSnapshotArchiveDirectory': + return await this.service.directory(identityKey, input.archiveId) + case 'readSnapshotArchivePage': + return await this.service.read(identityKey, input.archiveId, input.sequence) + case 'cancelSnapshotArchive': + await this.service.cancel(identityKey, input.requestId) + return true + } + } + + close(): Promise { + this.stopped = true + return this.service.close() + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts index 69567ee49..3f5a76906 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts @@ -7,6 +7,8 @@ import { StorageProvider } from '../../StorageProvider' import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' import { KnexSnapshotArchiveService } from './KnexSnapshotArchiveService' import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' +import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage } from './SnapshotArchiveDirectory' import * as ArchiveSql from './SnapshotArchiveSql' @@ -286,11 +288,16 @@ test('an already claimed request is not resumed under a replacement source', asy test('expiry between durable admission and source acquisition refuses without opening a pool', async () => { const { storage, controller } = await fixture() const input = request() - const now = jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow') - now - .mockResolvedValueOnce(input.notAfter - 100) - .mockResolvedValueOnce(input.notAfter - 100) - .mockResolvedValueOnce(input.notAfter) + const claim = KnexSnapshotArchiveRequestStore.prototype.claim + jest.spyOn(KnexSnapshotArchiveRequestStore.prototype, 'claim').mockImplementationOnce(async function ( + this: KnexSnapshotArchiveRequestStore, + key, + value + ) { + const admitted = await claim.call(this, key, value) + jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(input.notAfter) + return admitted + }) const open = jest.spyOn(storage, 'openSnapshotArchiveSource') await expect(controller.create(identity, input)).rejects.toThrow('expired before capture') expect(open).not.toHaveBeenCalled() @@ -334,3 +341,108 @@ test('failed cleanup while opening retains admission even though no source was r await reader.destroy() await new KnexSnapshotArchiveRequestStore(storage.knex).close(identity, input.requestId, 'failed') }) + +test('start returns a durable receipt before capture completes and repeats only that admission', async () => { + const { storage, controller, open } = await fixture() + const input = request() + const entered = gate() + const finish = gate() + const original = storage.openSnapshotArchiveSource.bind(storage) + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options) => { + entered.resolve() + await finish.promise + return await original(key, options) + }) + const accepted = controller.start(identity, input) + expect(controller.start(identity, { ...input })).toBe(accepted) + const completion = controller.create(identity, input) + void completion.catch(() => undefined) + try { + const receipt = await accepted + expect(receipt).toEqual({ version: 1, state: 'building', requestId: input.requestId, expiresAt: input.notAfter }) + expect(Object.isFrozen(receipt)).toBe(true) + await entered.promise + const replacementStorage = open() + await replacementStorage.makeAvailable() + const replacement = service(replacementStorage) + expect(await replacement.start(identity, input)).toEqual(receipt) + expect(Reflect.get(replacementStorage, 'snapshotSyncSource')).toBeUndefined() + finish.resolve() + const ready = await completion + expect(ready.state).toBe('ready') + expect(await replacement.start(identity, input)).toEqual(ready) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + await replacement.cancel(identity, input.requestId) + } finally { + finish.resolve() + await completion.catch(() => undefined) + } +}) + +test('resource exhaustion has a durable distinct terminal status and never replaces its archive on retry', async () => { + const { storage, controller, open } = await fixture() + const fields = { ...request(), maxBytes: 4097 } + const input = { ...fields, requestId: snapshotArchiveRequestId(fields) } + await expect(controller.create(identity, input)).rejects.toBeInstanceOf(SnapshotResourceLimitError) + expect(await controller.status(identity, input.requestId)).toEqual({ + version: 1, + requestId: input.requestId, + expiresAt: input.notAfter, + state: 'resource-limited' + }) + const replacementStorage = open() + await replacementStorage.makeAvailable() + const replacement = service(replacementStorage) + const reader = jest.spyOn(replacementStorage, 'openSnapshotArchiveSource') + expect((await replacement.start(identity, input)).state).toBe('resource-limited') + expect(reader).not.toHaveBeenCalled() + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await replacement.cancel(identity, input.requestId) + expect((await replacement.status(identity, input.requestId)).state).toBe('resource-limited') + expect((await replacement.create(identity, request('c'.repeat(64)))).state).toBe('ready') +}) + +test.each(['pending', 'capturing', 'ready', 'legacy'] as const)( + 'new admission reaps an abandoned expired %s capture', + async state => { + const { storage, controller } = await fixture() + const input = request() + const requests = new KnexSnapshotArchiveRequestStore(storage.knex) + const archives = new KnexSnapshotArchiveStore(storage.knex) + if (state === 'ready') await controller.create(identity, input) + else { + const admitted = state === 'legacy' ? undefined : await requests.claim(identity, input) + if (state === 'capturing' || state === 'legacy') { + const source = (await storage.openSnapshotArchiveSource(identity))! + const binding = { + version: 1 as const, + snapshotId: source.snapshotId, + sourceSchema: source.sourceSchema, + sourceStorage: source.sourceStorage, + user: source.user + } + await source.close() + const writer = + state === 'legacy' + ? await archives.begin(binding, { maxBytes: 32768, lifetimeMs: 200000 }) + : await requests.begin(admitted!.owner!, binding) + await archives.append(writer, { sequence: 0, table: 'provenTxs', rows: 0, done: true, bytes: Uint8Array.of(0) }) + } + } + const now = input.notAfter + 60000 + jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(now) + const fields = { ...input, nonce: 'c'.repeat(64), notAfter: now + 300000 } + const next = { ...fields, requestId: snapshotArchiveRequestId(fields) } + const ready = await controller.create(identity, next) + expect(ready.state).toBe('ready') + expect(await storage.knex('snapshot_archive_requests')).toHaveLength(1) + expect(await storage.knex('snapshot_archives')).toHaveLength(1) + expect((await storage.knex('snapshot_archives').first()).archiveId).toBe(ready.archiveId) + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ + archives: 1, + reservedBytes: next.maxBytes + }) + await controller.cancel(identity, next.requestId) + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts index 8975d3f57..51fbd6ea2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts @@ -7,7 +7,9 @@ import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' import { parseSnapshotArchiveRequest, type SnapshotArchiveRequest, - type SnapshotArchiveRequestReceipt + type SnapshotArchiveRequestReceipt, + type SnapshotArchiveRequestOwner, + type SnapshotArchiveTerminalState } from './SnapshotArchiveRequest' import { snapshotArchiveDatabaseNow } from './SnapshotArchiveSql' import { assertSnapshotArchiveCaptureActive, captureSnapshotArchiveSource } from './captureSnapshotArchiveSource' @@ -16,7 +18,8 @@ interface Capture { identityKey: string request: Readonly controller: AbortController - terminal: 'closed' | 'failed' + terminal: Exclude + accepted: Promise completion: Promise } @@ -50,15 +53,33 @@ export class KnexSnapshotArchiveService { * Completion waits for capture; status remains available while it runs. */ create(identityKey: string, input: unknown): Promise { + return this.admit(identityKey, input).completion + } + + /** Return the durable admission promptly; poll status while the owned capture continues. */ + start(identityKey: string, input: unknown): Promise { + return this.admit(identityKey, input).accepted + } + + private admit(identityKey: string, input: unknown): Capture { this.assertOpen() const request = parseSnapshotArchiveRequest(input) if (this.active !== undefined) { if (this.active.identityKey === identityKey && this.active.request.requestId === request.requestId) - return this.active.completion + return this.active throw new SnapshotResourceLimitError('Snapshot archive capture is opening or active') } - const completion = Promise.resolve() - .then(() => this.capture(job)) + const claim = Promise.resolve().then(async () => { + assertSnapshotArchiveCaptureActive(job.controller.signal) + // Reap outside the claim transaction, before reserving or opening a pool. + await this.requests.reap() + await this.archives.reap() + assertSnapshotArchiveCaptureActive(job.controller.signal) + return await this.requests.claim(identityKey, request) + }) + const accepted = claim.then(result => Object.freeze({ ...result.receipt })) + const completion = claim + .then(result => this.capture(job, result)) .finally(() => { if (this.active === job) this.active = undefined }) @@ -67,21 +88,25 @@ export class KnexSnapshotArchiveService { request, controller: new AbortController(), terminal: 'failed', + accepted, completion } this.active = job // Cancellation/shutdown can observe completion even after a caller disconnects. void completion.catch(() => undefined) - return completion + void accepted.catch(() => undefined) + return job } - private async capture(job: Capture): Promise { + private async capture( + job: Capture, + claimed: { receipt: SnapshotArchiveRequestReceipt; owner?: SnapshotArchiveRequestOwner } + ): Promise { const { identityKey, request, controller } = job - assertSnapshotArchiveCaptureActive(controller.signal) - const claimed = await this.requests.claim(identityKey, request) if (claimed.owner === undefined) return claimed.receipt let source: SnapshotArchiveSource | undefined - const cleanup = async (): Promise => { + const cleanup = async (error?: unknown): Promise => { + if (job.terminal !== 'closed' && error instanceof SnapshotResourceLimitError) job.terminal = 'resource-limited' try { // Keep the logical reservation through this process's physical cleanup. await source?.close() @@ -106,7 +131,7 @@ export class KnexSnapshotArchiveService { begin: binding => this.requests.begin(owner, binding), append: (writer, page) => this.archives.append(writer, page), seal: writer => this.requests.seal(owner, writer), - close: cleanup + close: (_identityKey, _archiveId, error) => cleanup(error) }, identityKey, this.storage.chain, @@ -115,7 +140,7 @@ export class KnexSnapshotArchiveService { return await this.requests.status(identityKey, request.requestId) } catch (error) { if (error instanceof SnapshotArchiveSourceCleanupError) this.failedCleanup(error) - await cleanup() + await cleanup(error) throw error } } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index f061071b8..a1c04e8a8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -20,7 +20,7 @@ export interface SnapshotArchiveSource extends WalletReadSnapshot { validateClosure: () => Promise } -async function sourceSchema(storage: StorageKnex, k: Knex): Promise { +export async function readSnapshotArchiveSourceSchema(storage: StorageKnex, k: Knex): Promise { const config = storage.knex.client.config.migrations const query = k(config?.tableName ?? 'knex_migrations') .select('name') @@ -52,7 +52,7 @@ export async function openKnexSnapshotArchiveSource( const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') - return { sourceStorage, user, sourceSchema: await sourceSchema(storage, storage.toDb(trx)) } + return { sourceStorage, user, sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) } }) const userId = header.user.userId const snapshotId = Utils.toHex(Random(32)) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveClientBoundary.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveClientBoundary.test.ts new file mode 100644 index 000000000..d5fe4458d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveClientBoundary.test.ts @@ -0,0 +1,257 @@ +import { AuthFetch, PrivateKey, ProtoWallet } from '@bsv/sdk' +import { StorageClient } from '../../remoting/StorageClient' +import { BINARY_ENCODING, BINARY_ENCODING_HEADER } from '../../remoting/BinaryJson' +import { + snapshotArchiveCapabilities, + snapshotArchiveRequestBytes, + snapshotArchiveResponseBytes +} from './SnapshotArchiveProtocol' +import { snapshotHttpFixture } from '../../../../test/utils/snapshotArchiveHttpFixtures' + +afterEach(() => jest.restoreAllMocks()) + +test('server requires bounded compact framing and refuses its own oversized snapshot result', async () => { + const fixture = await snapshotHttpFixture() + try { + const { server, url } = await fixture.serve() + const auth = new AuthFetch(fixture.wallet) + for (const [binary, id, padding] of [ + [false, 1, ''], + [true, 0, ''], + [true, '1', ''], + [true, 1, 'x'.repeat(snapshotArchiveRequestBytes)] + ] as const) { + const response = await auth.fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(binary ? { [BINARY_ENCODING_HEADER]: BINARY_ENCODING } : {}) + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'getSnapshotArchiveOffer', + params: [{ version: 1, identityKey: fixture.identityKey }], + id, + padding + }) + }) + const json = await response.json() + expect(json.error.message).toContain('bounded compact-binary RPC framing') + } + const client = new StorageClient(fixture.wallet, url) + const transport = (await client.getSnapshotArchiveTransport(fixture.identityKey))! + const rpc = Reflect.get(server, 'snapshotArchives') + jest.spyOn(rpc, 'dispatch').mockResolvedValue({ padding: 'x'.repeat(snapshotArchiveResponseBytes) }) + await expect(transport.offer()).rejects.toThrow('response exceeds its transport limit') + } finally { + await fixture.close() + } +}) + +test('dedicated snapshot response cap is enforced before JSON parsing over authenticated HTTP', async () => { + const fixture = await snapshotHttpFixture() + try { + const { server, url } = await fixture.serve() + const client = new StorageClient(fixture.wallet, url) + const transport = (await client.getSnapshotArchiveTransport(fixture.identityKey))! + const send = Reflect.get(server, 'sendRpc').bind(server) + // An authenticated peer can be a different implementation: bypass only + // this fixture's snapshot-specific response bound, retaining real signing. + jest.spyOn(server as never, 'sendRpc' as never).mockImplementation((( + res: unknown, + binary: boolean, + payload: { id: unknown; result: unknown } + ) => { + const large = { + jsonrpc: '2.0', + id: payload.id, + result: { padding: 'x'.repeat(snapshotArchiveResponseBytes + 1) } + } + return send(res, binary, large) + }) as never) + await expect(transport.offer()).rejects.toThrow(/limit|large|size|exceed/i) + const bounded = Reflect.get(client, 'snapshotAuthClient') + expect(Reflect.get(bounded, 'maxResponseBytes')).toBe(snapshotArchiveResponseBytes) + } finally { + await fixture.close() + } +}) + +test.each(['no-auth', 'changed-server', 'no-binary', 'wrong-id', 'non-ok'] as const)( + 'snapshot calls retain the shared authenticated response contract: %s', + async variant => { + const fixture = await snapshotHttpFixture() + try { + const { url } = await fixture.serve() + const client = new StorageClient(fixture.wallet, url, { serverIdentityKey: fixture.serverIdentityKey }) + const transport = (await client.getSnapshotArchiveTransport(fixture.identityKey))! + const expectedId = Reflect.get(client, 'nextId') + const identity = + variant === 'changed-server' ? PrivateKey.fromRandom().toPublicKey().toString() : fixture.serverIdentityKey + const headers = { + ...(variant === 'no-auth' ? {} : { 'x-bsv-auth-identity-key': identity }), + ...(variant === 'no-binary' ? {} : { [BINARY_ENCODING_HEADER]: BINARY_ENCODING }) + } + jest + .spyOn(AuthFetch.prototype, 'fetch') + .mockResolvedValue( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: variant === 'wrong-id' ? expectedId + 1 : expectedId, + result: { + version: 1, + serverTime: Date.now(), + sourceStorageIdentityKey: 'http-snapshot-source', + sourceSchema: 'schema', + chain: 'test' + } + }), + { headers, status: variant === 'non-ok' ? 503 : 200 } + ) + ) + const reason = { + 'no-auth': /not mutually authenticated/, + 'changed-server': /identity changed/, + 'no-binary': /compact binary responses/, + 'wrong-id': /id/, + 'non-ok': /503/ + }[variant] + await expect(transport.offer()).rejects.toThrow(reason) + } finally { + await fixture.close() + } + } +) + +test('oversized snapshot envelopes reject before transport and advertised settings are privately detached', async () => { + const fixture = await snapshotHttpFixture() + try { + const { url } = await fixture.serve() + const client = new StorageClient(fixture.wallet, url) + const settings = await client.makeAvailable() + settings.storageIdentityKey = 'caller-mutated' + settings.chain = 'main' + settings.snapshotArchive = undefined + const transport = (await client.getSnapshotArchiveTransport(fixture.identityKey))! + expect((await transport.offer()).sourceStorageIdentityKey).toBe('http-snapshot-source') + const fetch = jest.spyOn(AuthFetch.prototype, 'fetch') + const call = Reflect.get(client, 'snapshotRpcCall').bind(client) + await expect( + call('getSnapshotArchiveOffer', [{ padding: 'x'.repeat(snapshotArchiveRequestBytes) }]) + ).rejects.toThrow('transport limit') + expect(fetch).not.toHaveBeenCalled() + } finally { + await fixture.close() + } +}) + +test('legacy settings decline the capability and malformed advertisements fail rather than fall back', async () => { + const wallet = new ProtoWallet(PrivateKey.fromRandom()) + for (const value of [undefined, null, { ...snapshotArchiveCapabilities, version: 2 }, snapshotArchiveCapabilities]) { + const client = new StorageClient(wallet, 'http://localhost:1') + jest.spyOn(client as never, 'rpcCall' as never).mockResolvedValue({ + storageIdentityKey: 'legacy', + ...(value === undefined ? {} : { snapshotArchive: value }) + } as never) + if (value !== undefined && value !== snapshotArchiveCapabilities) { + await expect(client.makeAvailable()).rejects.toThrow() + } else { + expect(await client.getSnapshotArchiveTransport(PrivateKey.fromRandom().toPublicKey().toString())).toBeUndefined() + } + } +}) + +test.each([ + [4095, snapshotArchiveResponseBytes, false], + [4096, snapshotArchiveResponseBytes, true], + [4096, -1, true], + [4096, snapshotArchiveResponseBytes - 1, false] +] as const)('capability respects body %p and response %p limits', async (bodyLimit, responseLimit, enabled) => { + const fixture = await snapshotHttpFixture() + const previous = process.env.WALLET_STORAGE_JSON_MAX_BODY_BYTES + try { + process.env.WALLET_STORAGE_JSON_MAX_BODY_BYTES = String(bodyLimit) + const { url } = await fixture.serve({ maxRpcResponseBytes: responseLimit }) + const client = new StorageClient(fixture.wallet, url) + const settings = await client.makeAvailable() + expect(settings.snapshotArchive).toEqual(enabled ? snapshotArchiveCapabilities : undefined) + } finally { + if (previous === undefined) delete process.env.WALLET_STORAGE_JSON_MAX_BODY_BYTES + else process.env.WALLET_STORAGE_JSON_MAX_BODY_BYTES = previous + await fixture.close() + } +}) + +test('main-chain client advertises a usable transport and admits an exactly bounded request', async () => { + const key = PrivateKey.fromRandom().toPublicKey().toString() + const client = new StorageClient(new ProtoWallet(PrivateKey.fromRandom()), 'http://localhost:1') + jest.spyOn(client as never, 'rpcCall' as never).mockResolvedValue({ + storageIdentityKey: 'main-source', + chain: 'main', + snapshotArchive: snapshotArchiveCapabilities + } as never) + const transport = (await client.getSnapshotArchiveTransport(key))! + const fetch = jest.spyOn(AuthFetch.prototype, 'fetch').mockImplementation(async (_url, options) => { + const input = JSON.parse(options!.body as string) + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: input.id, + result: { + version: 1, + serverTime: Date.now(), + sourceStorageIdentityKey: 'main-source', + sourceSchema: 'schema', + chain: 'main' + } + }), + { headers: { 'x-bsv-auth-identity-key': key, [BINARY_ENCODING_HEADER]: BINARY_ENCODING } } + ) + }) + expect((await transport.offer()).chain).toBe('main') + const call = Reflect.get(client, 'snapshotRpcCall').bind(client) + const params = [{ padding: '' }] + const base = JSON.stringify({ + jsonrpc: '2.0', + method: 'getSnapshotArchiveOffer', + params, + id: Reflect.get(client, 'nextId') + }) + params[0].padding = 'x'.repeat(snapshotArchiveRequestBytes - Buffer.byteLength(base)) + await expect(call('getSnapshotArchiveOffer', params)).resolves.toMatchObject({ chain: 'main' }) + expect(Buffer.byteLength(fetch.mock.calls.at(-1)![1]!.body as string)).toBe(snapshotArchiveRequestBytes) +}) + +test('the server admits exact request/response ceilings, including request ID one', async () => { + const fixture = await snapshotHttpFixture() + try { + const { server, url } = await fixture.serve() + const auth = new AuthFetch(fixture.wallet) + const input = { + jsonrpc: '2.0', + method: 'getSnapshotArchiveOffer', + params: [{ version: 1, identityKey: fixture.identityKey }], + id: 1, + padding: '' + } + input.padding = 'x'.repeat(snapshotArchiveRequestBytes - Buffer.byteLength(JSON.stringify(input))) + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json', [BINARY_ENCODING_HEADER]: BINARY_ENCODING }, + body: JSON.stringify(input) + } + const accepted = await auth.fetch(url, options) + expect(await accepted.json()).toMatchObject({ jsonrpc: '2.0', id: 1, result: { version: 1, chain: 'test' } }) + const rpc = Reflect.get(server, 'snapshotArchives') + const base = JSON.stringify({ jsonrpc: '2.0', result: { padding: '' }, id: 1 }) + jest + .spyOn(rpc, 'dispatch') + .mockResolvedValue({ padding: 'x'.repeat(snapshotArchiveResponseBytes - Buffer.byteLength(base)) }) + const response = await auth.fetch(url, options) + expect(response.status).toBe(200) + expect(Buffer.byteLength(await response.text())).toBe(snapshotArchiveResponseBytes) + } finally { + await fixture.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts new file mode 100644 index 000000000..63e284d93 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts @@ -0,0 +1,215 @@ +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { StorageClient } from '../../remoting/StorageClient' +import { StorageClient as StorageMobile } from '../../remoting/StorageMobile' +import { KnexSnapshotArchiveService } from './KnexSnapshotArchiveService' +import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { snapshotArchiveCapabilities } from './SnapshotArchiveProtocol' +import { snapshotHttpFixture, gate } from '../../../../test/utils/snapshotArchiveHttpFixtures' +import { decodeSyncTransfer } from '../../remoting/SyncTransfer' + +afterEach(() => jest.restoreAllMocks()) + +test.each([StorageClient, StorageMobile])( + 'authenticated %p capture survives lost admission and server replacement without recapture', + async Client => { + const fixture = await snapshotHttpFixture() + const allowOpen = gate() + try { + const { storage, identityKey, wallet, serve } = fixture + let { server, url } = await serve() + let client = new Client(wallet, url, { + serverIdentityKey: fixture.serverIdentityKey, + storageIdentityKey: 'http-snapshot-source' + }) + const settings = await client.makeAvailable() + const ordinaryRpc = Reflect.get(client, 'rpcCall').bind(client) + await expect(ordinaryRpc('releaseSyncTransfer', [{ identityKey, transferId: 'e'.repeat(64) }])).rejects.toThrow( + 'Wallet sync transfer expired' + ) + await expect( + ordinaryRpc('readSyncTransferPart', [{ identityKey, transferId: 'e'.repeat(64), offset: 0 }]) + ).rejects.toThrow('Wallet sync transfer expired') + expect(settings.snapshotArchive).toEqual(snapshotArchiveCapabilities) + expect(storage.getSettings()).not.toHaveProperty('snapshotArchive') + let transport = (await client.getSnapshotArchiveTransport(identityKey))! + const offer = await transport.offer() + expect(offer.sourceSchema).toBe('2026-09-30-003 add snapshot archive requests') + expect(Math.abs(offer.serverTime - Date.now())).toBeLessThan(5000) + const fields = { + version: 1 as const, + nonce: 'a'.repeat(64), + notAfter: offer.serverTime + 300000, + maxBytes: 32768 + } + const request = { ...fields, requestId: snapshotArchiveRequestId(fields) } + const entered = gate() + const original = storage.openSnapshotArchiveSource.bind(storage) + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementation(async (...args) => { + entered.resolve() + await allowOpen.promise + return await original(...args) + }) + const rpc = Reflect.get(client, 'snapshotRpcCall').bind(client) + const lost = jest.spyOn(client as never, 'snapshotRpcCall' as never).mockImplementation((async ( + method: string, + params: unknown[], + signal?: AbortSignal + ) => { + const response = await rpc(method, params, signal) + if (method === 'startSnapshotArchive') throw new Error('synthetic lost admission acknowledgement') + return response + }) as never) + await expect(transport.start(request)).rejects.toThrow('lost admission') + await entered.promise + lost.mockRestore() + expect((await transport.status(request)).state).toBe('building') + expect((await transport.start(request)).state).toBe('building') + expect(opening).toHaveBeenCalledTimes(1) + // A foreground write is possible while the owned reader is opening. + await storage.knex('tx_labels').where({ txLabelId: 1 }).update({ label: 'foreground' }) + const controller = Reflect.get(Reflect.get(server, 'snapshotArchives'), 'service') as KnexSnapshotArchiveService + allowOpen.resolve() + const ready = await controller.create(identityKey, request) + expect(await transport.status(request)).toEqual(ready) + const verified = await transport.directory(ready, offer, Date.now()) + expect(verified.manifest.pages).toBe(13) + const page = await transport.page(verified, verified.tables.txLabels.first) + const decoded = decodeSyncTransfer(page) as { table: string; rows: unknown[] } + expect(decoded.table).toBe('txLabels') + expect(decoded.rows).toEqual([ + expect.objectContaining({ txLabelId: 1, label: 'foreground', isDeleted: false }), + expect.objectContaining({ txLabelId: 3, isDeleted: true }) + ]) + expect(JSON.stringify(ready)).not.toMatch(/claimToken|writerToken/) + expect(JSON.stringify(verified)).not.toMatch(/claimToken|writerToken/) + + const strangerKey = PrivateKey.fromRandom() + const stranger = new Client(new ProtoWallet(strangerKey), url) + const foreignTransport = (await stranger.getSnapshotArchiveTransport(identityKey))! + await expect(foreignTransport.offer()).rejects.toThrow('match authentication') + await expect(foreignTransport.start(request)).rejects.toThrow('match authentication') + await expect(foreignTransport.status(request)).rejects.toThrow('match authentication') + await expect(foreignTransport.cancel(request.requestId)).rejects.toThrow('match authentication') + const ownForeign = (await stranger.getSnapshotArchiveTransport(strangerKey.toPublicKey().toString()))! + await expect(ownForeign.status(request)).rejects.toThrow('unavailable') + await expect(ownForeign.page(verified, 0)).rejects.toThrow('unavailable') + await ownForeign.cancel(request.requestId) + expect(await transport.status(request)).toEqual(ready) + + await server.close() + ;({ server, url } = await serve()) + client = new Client(wallet, url, { serverIdentityKey: fixture.serverIdentityKey }) + transport = (await client.getSnapshotArchiveTransport(identityKey))! + expect(await transport.start(request)).toEqual(ready) + expect(opening).toHaveBeenCalledTimes(1) + expect( + await transport.page(await transport.directory(ready, offer, Date.now()), verified.tables.txLabels.first) + ).toEqual(page) + await transport.cancel(request.requestId) + expect((await transport.start(request)).state).toBe('closed') + await expect(transport.page(verified, 0)).rejects.toThrow('unavailable') + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + } finally { + allowOpen.resolve() + await fixture.close() + } + } +) + +test.each(['server-disabled', 'provider-disabled', 'small-response', 'old-schema'] as const)( + 'does not advertise snapshot transport for %s and legacy RPC still works', + async variant => { + const fixture = await snapshotHttpFixture(variant !== 'provider-disabled') + try { + if (variant === 'old-schema') await fixture.storage.knex.schema.dropTable('snapshot_archive_requests') + const { url } = await fixture.serve({ + snapshotArchives: variant !== 'server-disabled', + ...(variant === 'small-response' ? { maxRpcResponseBytes: 8192 } : {}) + }) + const client = new StorageClient(fixture.wallet, url) + expect((await client.makeAvailable()).snapshotArchive).toBeUndefined() + expect(await client.getSnapshotArchiveTransport(fixture.identityKey)).toBeUndefined() + expect((await client.findOrInsertUser(fixture.identityKey)).user.identityKey).toBe(fixture.identityKey) + const rpc = Reflect.get(client, 'rpcCall').bind(client) + await expect(rpc('getSnapshotArchiveOffer', [{ version: 1, identityKey: fixture.identityKey }])).rejects.toThrow( + variant === 'server-disabled' || variant === 'small-response' + ? 'network error 400' + : 'unavailable' + ) + } finally { + await fixture.close() + } + } +) + +test('client rollback disables snapshots; small capture budget has a durable distinct resource-limit receipt', async () => { + const fixture = await snapshotHttpFixture() + try { + const { server, url } = await fixture.serve() + const disabled = new StorageMobile(fixture.wallet, url, { snapshotArchives: false }) + expect(await disabled.getSnapshotArchiveTransport(fixture.identityKey)).toBeUndefined() + const client = new StorageClient(fixture.wallet, url) + const transport = (await client.getSnapshotArchiveTransport(fixture.identityKey))! + const offer = await transport.offer() + const fields = { version: 1 as const, nonce: 'b'.repeat(64), notAfter: offer.serverTime + 300000, maxBytes: 4097 } + const request = { ...fields, requestId: snapshotArchiveRequestId(fields) } + expect((await transport.start(request)).state).toBe('building') + const service = Reflect.get(Reflect.get(server, 'snapshotArchives'), 'service') as KnexSnapshotArchiveService + await service.create(fixture.identityKey, request).catch(() => undefined) + expect((await transport.status(request)).state).toBe('resource-limited') + expect((await transport.start(request)).state).toBe('resource-limited') + expect(await fixture.storage.knex('snapshot_archive_capacity').first()).toMatchObject({ + archives: 0, + reservedBytes: 0 + }) + } finally { + await fixture.close() + } +}) + +test('server close fences admission and awaits opening capture, physical pool cleanup and HTTP callback', async () => { + const fixture = await snapshotHttpFixture() + const allowOpen = gate() + try { + const { server, url } = await fixture.serve() + const client = new StorageClient(fixture.wallet, url) + const transport = (await client.getSnapshotArchiveTransport(fixture.identityKey))! + const offer = await transport.offer() + const fields = { version: 1 as const, nonce: 'c'.repeat(64), notAfter: offer.serverTime + 300000, maxBytes: 32768 } + const request = { ...fields, requestId: snapshotArchiveRequestId(fields) } + const entered = gate() + const original = fixture.storage.openSnapshotArchiveSource.bind(fixture.storage) + jest.spyOn(fixture.storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options) => { + const source = await original(key, { ...options, signal: undefined }) + entered.resolve() + await allowOpen.promise + return source + }) + await transport.start(request) + await entered.promise + let closed = false + const pending = server.close().then(() => { + closed = true + }) + expect(server.close()).toBe(Reflect.get(server, 'closing')) + expect(() => server.start()).toThrow('closing') + await new Promise(resolve => setImmediate(resolve)) + expect(closed).toBe(false) + expect(server.server.listening).toBe(false) + allowOpen.resolve() + await pending + expect(closed).toBe(true) + expect(Reflect.get(fixture.storage, 'snapshotSyncSource')).toBeUndefined() + expect(await fixture.storage.knex('snapshot_archive_capacity').first()).toMatchObject({ + archives: 0, + reservedBytes: 0 + }) + // The historical reusable-server lifecycle still works after completed close. + server.start() + if (!server.server.listening) await new Promise(resolve => server.server.once('listening', resolve)) + expect(server.server.listening).toBe(true) + } finally { + allowOpen.resolve() + await fixture.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts new file mode 100644 index 000000000..d9f2568ef --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts @@ -0,0 +1,73 @@ +import fc from 'fast-check' +import { createHash } from 'node:crypto' +import { + parseSnapshotArchiveRpcInput, + snapshotArchiveMethods, + validateSnapshotArchiveRequestReceipt +} from './SnapshotArchiveProtocol' +import { SnapshotArchiveTransport } from './SnapshotArchiveTransport' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +test('generated protocol requests preserve exact identity, deadlines and terminal states through client validation', async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 32, maxLength: 32 }), + fc.integer({ min: 1, max: 3600000 }), + fc.integer({ min: 4097, max: 32 * 1024 * 1024 }), + fc.constantFrom('building', 'closed', 'failed', 'expired', 'resource-limited', 'ready'), + fc.constantFrom(...snapshotArchiveMethods), + async (bytes, duration, maxBytes, state, method) => { + const nonce = Buffer.from(bytes).toString('hex') + const identityKey = '02' + nonce + const notAfter = 1790726400000 + duration + const requestId = createHash('sha256') + .update(JSON.stringify(['wallet-snapshot-request/1', 1, nonce, notAfter, maxBytes])) + .digest('hex') + const request = { version: 1 as const, nonce, notAfter, maxBytes, requestId } + const archiveId = createHash('sha256') + .update('archive:' + nonce) + .digest('hex') + const digest = createHash('sha256') + .update('directory:' + nonce) + .digest('hex') + const receipt = { + version: 1, + requestId, + expiresAt: notAfter, + state, + ...(state === 'ready' ? { archiveId, digest } : {}) + } + const rpc = jest.fn(async (_method: string, _params: unknown[]) => receipt) + const transport = new SnapshotArchiveTransport(rpc, identityKey, 'source', 'test') + expect(await transport.start(request)).toEqual(receipt) + expect(await transport.status(request)).toEqual(receipt) + expect(rpc.mock.calls[0]).toEqual(['startSnapshotArchive', [{ version: 1, identityKey, request }], undefined]) + const extra = + method === 'startSnapshotArchive' + ? { request } + : method === 'getSnapshotArchiveStatus' || method === 'cancelSnapshotArchive' + ? { requestId } + : method === 'getSnapshotArchiveDirectory' + ? { archiveId } + : method === 'readSnapshotArchivePage' + ? { archiveId, sequence: duration % 4096 } + : {} + const input = { version: 1, identityKey, ...extra } + expect(parseSnapshotArchiveRpcInput(method, [input])).toEqual({ method, identityKey, ...extra }) + expect(() => parseSnapshotArchiveRpcInput(method, [{ ...input, claimToken: nonce }])).toThrow() + expect(() => validateSnapshotArchiveRequestReceipt({ ...receipt, expiresAt: notAfter + 1 }, request)).toThrow() + expect(() => validateSnapshotArchiveRequestReceipt({ ...receipt, requestId: archiveId }, request)).toThrow() + expect(() => validateSnapshotArchiveRequestReceipt({ ...receipt, writerToken: nonce }, request)).toThrow() + } + ) + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.test.ts new file mode 100644 index 000000000..3c994ead5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.test.ts @@ -0,0 +1,205 @@ +import { + snapshotArchiveCapabilities, + snapshotArchiveMethods, + snapshotArchiveResponseBytes, + parseSnapshotArchiveRpcInput, + validateSnapshotArchiveCapabilities, + validateSnapshotArchiveOffer, + validateSnapshotArchiveRequestReceipt +} from './SnapshotArchiveProtocol' +import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { verifySnapshotArchiveDirectory } from './SnapshotArchiveDirectory' +import { stringifyJsonRpc } from '../../remoting/BinaryJson' +import { fixture, expected, now, rehash } from '../../../../test/utils/snapshotArchiveDirectoryFixtures' + +const identityKey = '02' + 'ab'.repeat(32) +const fields = { version: 1 as const, nonce: 'a'.repeat(64), notAfter: now + 300000, maxBytes: 32768 } +const request = { ...fields, requestId: snapshotArchiveRequestId(fields) } +const offer = { version: 1, serverTime: now, sourceStorageIdentityKey: 'source', sourceSchema: 'schema', chain: 'test' } +const receipt = { version: 1, requestId: request.requestId, expiresAt: request.notAfter, state: 'building' } +const wire = (value: unknown) => + Buffer.byteLength( + stringifyJsonRpc({ jsonrpc: '2.0', id: Number.MAX_SAFE_INTEGER, result: value }, true).replace(/[<>&]/g, '\\u003c') + ) + +test('detaches and freezes validated capability, offer and all exact receipt states', () => { + expect(validateSnapshotArchiveCapabilities({ ...snapshotArchiveCapabilities })).toBe(snapshotArchiveCapabilities) + expect(snapshotArchiveCapabilities).toEqual({ + version: 1, + encoding: 'wallet-snapshot-rows/1', + maxResponseBytes: 2097152, + maxArchiveBytes: 33554432, + maxPageBytes: 1048576, + maxPages: 4096, + maxLifetimeMs: 3600000 + }) + expect(Object.isFrozen(snapshotArchiveCapabilities)).toBe(true) + const validated = validateSnapshotArchiveOffer(offer, 'source', 'test') + expect(validated).toEqual(offer) + expect(validated).not.toBe(offer) + expect(Object.isFrozen(validated)).toBe(true) + for (const state of ['building', 'closed', 'failed', 'expired', 'resource-limited', 'ready']) { + const input = { + ...receipt, + state, + ...(state === 'ready' ? { archiveId: 'b'.repeat(64), digest: 'c'.repeat(64) } : {}) + } + const accepted = validateSnapshotArchiveRequestReceipt(input, request) + expect(accepted).toEqual(input) + expect(accepted).not.toBe(input) + expect(Object.isFrozen(accepted)).toBe(true) + } +}) + +test.each([null, undefined, 1, [], 'protocol'])('rejects non-record protocol values %p', value => { + expect(() => validateSnapshotArchiveCapabilities(value)).toThrow('Invalid snapshot archive protocol') + expect(() => validateSnapshotArchiveOffer(value, 'source', 'test')).toThrow() + expect(() => validateSnapshotArchiveRequestReceipt(value, request)).toThrow() +}) + +test('capability fields are exact, own enumerable data and immutable version-one limits', () => { + for (const field of Object.keys(snapshotArchiveCapabilities)) { + expect(() => validateSnapshotArchiveCapabilities({ ...snapshotArchiveCapabilities, [field]: 'wrong' })).toThrow() + const missing = { ...snapshotArchiveCapabilities } as Record + delete missing[field] + expect(() => validateSnapshotArchiveCapabilities(missing)).toThrow() + Object.defineProperty(missing, field, { value: Reflect.get(snapshotArchiveCapabilities, field) }) + expect(() => validateSnapshotArchiveCapabilities(missing)).toThrow() + } + const accessor = { ...snapshotArchiveCapabilities } + Object.defineProperty(accessor, 'version', { + get: () => { + throw new Error('Getter must not run') + } + }) + expect(() => validateSnapshotArchiveCapabilities(accessor)).toThrow('Invalid snapshot archive protocol') + expect(() => + validateSnapshotArchiveCapabilities({ ...snapshotArchiveCapabilities, [Symbol('extra')]: true }) + ).toThrow() + const inherited = Object.create(snapshotArchiveCapabilities) + Object.defineProperty(inherited, 'placeholder', { value: true }) + expect(() => validateSnapshotArchiveCapabilities(inherited)).toThrow() + expect(() => validateSnapshotArchiveCapabilities({ ...snapshotArchiveCapabilities, extra: true })).toThrow() +}) + +test.each([ + { version: 2 }, + { sourceStorageIdentityKey: 'changed' }, + { chain: 'main' }, + { sourceSchema: '' }, + { sourceSchema: 'x'.repeat(257) }, + { sourceSchema: null }, + { serverTime: -1 }, + { serverTime: 0.1 }, + { serverTime: NaN }, + { serverTime: Infinity }, + { serverTime: Number.MAX_SAFE_INTEGER + 1 }, + { serverTime: '123' } +])('refuses changed or malformed offer %p', changes => { + expect(() => validateSnapshotArchiveOffer({ ...offer, ...changes }, 'source', 'test')).toThrow() +}) + +test('offer integer and schema boundaries are inclusive', () => { + expect( + validateSnapshotArchiveOffer({ ...offer, serverTime: 0, sourceSchema: 'x' }, 'source', 'test').serverTime + ).toBe(0) + expect( + validateSnapshotArchiveOffer( + { ...offer, serverTime: Number.MAX_SAFE_INTEGER, sourceSchema: 'x'.repeat(256) }, + 'source', + 'test' + ).sourceSchema + ).toHaveLength(256) +}) + +test.each([ + { version: 0 }, + { requestId: 'b'.repeat(64) }, + { expiresAt: request.notAfter + 1 }, + { state: 'other' }, + { state: null }, + { claimToken: 'internal' }, + { archiveId: 'a'.repeat(64) } +])('receipt is bound to exact request and fields %p', changes => { + expect(() => validateSnapshotArchiveRequestReceipt({ ...receipt, ...changes }, request)).toThrow() +}) + +test.each(['archiveId', 'digest'])('ready receipt requires a canonical %s', field => { + for (const value of [undefined, '', 'A'.repeat(64), 'g'.repeat(64), 'a'.repeat(63), 'a'.repeat(65), 1]) { + expect(() => + validateSnapshotArchiveRequestReceipt( + { ...receipt, state: 'ready', archiveId: 'b'.repeat(64), digest: 'c'.repeat(64), [field]: value }, + request + ) + ).toThrow() + } +}) + +test('every RPC has exact typed arguments and no ownership-token parameter', () => { + for (const method of snapshotArchiveMethods) { + const extra = + method === 'startSnapshotArchive' + ? { request } + : method === 'getSnapshotArchiveStatus' || method === 'cancelSnapshotArchive' + ? { requestId: request.requestId } + : method === 'getSnapshotArchiveDirectory' + ? { archiveId: 'b'.repeat(64) } + : method === 'readSnapshotArchivePage' + ? { archiveId: 'b'.repeat(64), sequence: 0 } + : {} + const input = { version: 1, identityKey, ...extra } + expect(parseSnapshotArchiveRpcInput(method, [input])).toEqual({ method, identityKey, ...extra }) + for (const params of [ + [], + [input, input], + [null], + [{ ...input, writerToken: 'x' }], + [{ ...input, version: 2 }], + [{ ...input, identityKey: '' }], + [{ ...input, identityKey: 1 }] + ]) { + expect(() => parseSnapshotArchiveRpcInput(method, params)).toThrow() + } + } +}) + +test.each([-1, 0.1, 4096, Infinity, NaN, '0', null])('rejects page sequence %p before dispatch', sequence => { + expect(() => + parseSnapshotArchiveRpcInput('readSnapshotArchivePage', [ + { version: 1, identityKey, archiveId: 'b'.repeat(64), sequence } + ]) + ).toThrow() +}) + +test('a maximum valid directory and maximum binary page fit the escaped RPC response ceiling', () => { + const { directory, binding } = fixture(4083) + binding.sourceStorage.storageName = '<>&'.repeat(85) + binding.sourceSchema = '<>&'.repeat(85) + directory.bindingJson = JSON.stringify(binding) + rehash(directory) + expect(verifySnapshotArchiveDirectory(directory, expected, now).receipts).toHaveLength(4096) + expect(wire(directory)).toBeLessThan(snapshotArchiveResponseBytes) + expect(wire({ ...directory.receipts[0], bytes: new Uint8Array(1024 * 1024) })).toBeLessThan( + snapshotArchiveResponseBytes + ) + expect( + parseSnapshotArchiveRpcInput('readSnapshotArchivePage', [ + { version: 1, identityKey, archiveId: 'b'.repeat(64), sequence: 4095 } + ]) + ).toMatchObject({ sequence: 4095 }) +}) + +test('identity matching anchors the complete string, and schema values cannot coerce from objects', () => { + for (const value of ['prefix' + identityKey, identityKey + 'suffix', Object(identityKey)]) { + expect(() => parseSnapshotArchiveRpcInput('getSnapshotArchiveOffer', [{ version: 1, identityKey: value }])).toThrow( + 'Invalid snapshot archive protocol' + ) + } + expect(() => validateSnapshotArchiveOffer({ ...offer, sourceSchema: { length: 12 } }, 'source', 'test')).toThrow( + 'Invalid snapshot archive protocol' + ) + const missing = { ...snapshotArchiveCapabilities } as Record + delete missing.version + missing.placeholder = 1 + expect(() => validateSnapshotArchiveCapabilities(missing)).toThrow('Invalid snapshot archive protocol') +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts new file mode 100644 index 000000000..7628393d8 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts @@ -0,0 +1,173 @@ +import { snapshotArchiveLimits } from './SnapshotArchive' +import { snapshotArchiveEncoding } from './SnapshotArchiveDirectory' +import { + parseSnapshotArchiveRequest, + type SnapshotArchiveRequest, + type SnapshotArchiveRequestReceipt +} from './SnapshotArchiveRequest' + +/** Includes JSON-RPC framing, compact binary tags and HTML escaping. */ +export const snapshotArchiveResponseBytes = 2 * 1024 * 1024 +export const snapshotArchiveRequestBytes = 4096 +export const snapshotArchiveCapabilities = Object.freeze({ + version: 1 as const, + encoding: snapshotArchiveEncoding, + maxResponseBytes: snapshotArchiveResponseBytes, + maxArchiveBytes: snapshotArchiveLimits.archiveBytes, + maxPageBytes: snapshotArchiveLimits.pageBytes, + maxPages: snapshotArchiveLimits.pages, + maxLifetimeMs: snapshotArchiveLimits.lifetimeMs +}) +export type SnapshotArchiveCapabilities = typeof snapshotArchiveCapabilities + +export const snapshotArchiveMethods = Object.freeze([ + 'getSnapshotArchiveOffer', + 'startSnapshotArchive', + 'getSnapshotArchiveStatus', + 'getSnapshotArchiveDirectory', + 'readSnapshotArchivePage', + 'cancelSnapshotArchive' +] as const) +export type SnapshotArchiveMethod = (typeof snapshotArchiveMethods)[number] + +export interface SnapshotArchiveOffer { + version: 1 + serverTime: number + sourceStorageIdentityKey: string + sourceSchema: string + chain: 'main' | 'test' +} + +function invalid(): never { + throw new TypeError('Invalid snapshot archive protocol value') +} + +function record(input: unknown, names: readonly string[]): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) invalid() + if (Reflect.ownKeys(input).length !== names.length) invalid() + const result: Record = {} + for (const name of names) { + const property = Object.getOwnPropertyDescriptor(input, name) + if (property === undefined || !('value' in property) || !property.enumerable) invalid() + result[name] = property.value + } + return result +} + +function integer(input: unknown, minimum: number, maximum = Number.MAX_SAFE_INTEGER): number { + if (typeof input !== 'number' || !Number.isSafeInteger(input) || input < minimum || input > maximum) invalid() + return input +} + +function digest(input: unknown): string { + if (typeof input !== 'string' || !/^[0-9a-f]{64}$/.test(input)) invalid() + return input +} + +export function validateSnapshotArchiveCapabilities(input: unknown): SnapshotArchiveCapabilities { + const value = record(input, Object.keys(snapshotArchiveCapabilities)) + for (const [key, expected] of Object.entries(snapshotArchiveCapabilities)) { + if (value[key] !== expected) invalid() + } + return snapshotArchiveCapabilities +} + +export type SnapshotArchiveRpcInput = + | { method: 'getSnapshotArchiveOffer'; identityKey: string } + | { method: 'startSnapshotArchive'; identityKey: string; request: Readonly } + | { method: 'getSnapshotArchiveStatus' | 'cancelSnapshotArchive'; identityKey: string; requestId: string } + | { method: 'getSnapshotArchiveDirectory'; identityKey: string; archiveId: string } + | { method: 'readSnapshotArchivePage'; identityKey: string; archiveId: string; sequence: number } + +/** Exact method-specific data only; ownership tokens have no wire representation. */ +export function parseSnapshotArchiveRpcInput( + method: SnapshotArchiveMethod, + params: unknown[] +): SnapshotArchiveRpcInput { + if (params.length !== 1) invalid() + const extras = + method === 'startSnapshotArchive' + ? ['request'] + : method === 'getSnapshotArchiveStatus' || method === 'cancelSnapshotArchive' + ? ['requestId'] + : method === 'getSnapshotArchiveDirectory' + ? ['archiveId'] + : method === 'readSnapshotArchivePage' + ? ['archiveId', 'sequence'] + : [] + const input = record(params[0], ['version', 'identityKey', ...extras]) + if (input.version !== 1 || typeof input.identityKey !== 'string' || !/^(02|03)[0-9a-f]{64}$/.test(input.identityKey)) + invalid() + const identityKey = input.identityKey + switch (method) { + case 'getSnapshotArchiveOffer': + return { method, identityKey } + case 'startSnapshotArchive': + return { method, identityKey, request: parseSnapshotArchiveRequest(input.request) } + case 'getSnapshotArchiveStatus': + case 'cancelSnapshotArchive': + return { method, identityKey, requestId: digest(input.requestId) } + case 'getSnapshotArchiveDirectory': + return { method, identityKey, archiveId: digest(input.archiveId) } + case 'readSnapshotArchivePage': + return { + method, + identityKey, + archiveId: digest(input.archiveId), + sequence: integer(input.sequence, 0, snapshotArchiveLimits.pages - 1) + } + } +} + +export function validateSnapshotArchiveOffer( + input: unknown, + storageIdentityKey: string, + chain: 'main' | 'test' +): Readonly { + const value = record(input, ['version', 'serverTime', 'sourceStorageIdentityKey', 'sourceSchema', 'chain']) + if ( + value.version !== 1 || + value.sourceStorageIdentityKey !== storageIdentityKey || + value.chain !== chain || + typeof value.sourceSchema !== 'string' || + value.sourceSchema.length < 1 || + value.sourceSchema.length > 256 + ) + invalid() + return Object.freeze({ + version: 1, + serverTime: integer(value.serverTime, 0), + sourceStorageIdentityKey: storageIdentityKey, + sourceSchema: value.sourceSchema, + chain + }) +} + +export function validateSnapshotArchiveRequestReceipt( + input: unknown, + request: SnapshotArchiveRequest +): Readonly { + const state = + input !== null && typeof input === 'object' ? Object.getOwnPropertyDescriptor(input, 'state')?.value : undefined + const value = record(input, [ + 'version', + 'requestId', + 'expiresAt', + 'state', + ...(state === 'ready' ? ['archiveId', 'digest'] : []) + ]) + if ( + value.version !== 1 || + value.requestId !== request.requestId || + value.expiresAt !== request.notAfter || + !['building', 'ready', 'closed', 'failed', 'expired', 'resource-limited'].includes(state) + ) + invalid() + return Object.freeze({ + version: 1, + requestId: request.requestId, + expiresAt: request.notAfter, + state, + ...(state === 'ready' ? { archiveId: digest(value.archiveId), digest: digest(value.digest) } : {}) + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.ts index 061653c30..696cabb8a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveRequest.ts @@ -9,11 +9,13 @@ export interface SnapshotArchiveRequest { requestId: string } +export type SnapshotArchiveTerminalState = 'closed' | 'failed' | 'expired' | 'resource-limited' + export interface SnapshotArchiveRequestReceipt { version: 1 requestId: string expiresAt: number - state: 'building' | 'ready' | 'closed' | 'failed' | 'expired' + state: 'building' | 'ready' | SnapshotArchiveTerminalState archiveId?: string digest?: string } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveServerLifecycle.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveServerLifecycle.test.ts new file mode 100644 index 000000000..757a2d753 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveServerLifecycle.test.ts @@ -0,0 +1,94 @@ +import { once } from 'node:events' +import { StorageServer } from '../../remoting/StorageServer' +import { StorageProvider } from '../../StorageProvider' +import { Wallet } from '../../../Wallet' +import { gate, snapshotHttpFixture } from '../../../../test/utils/snapshotArchiveHttpFixtures' + +afterEach(() => jest.restoreAllMocks()) + +function bareServer() { + return new StorageServer({} as StorageProvider, { + port: 0, + wallet: { chain: 'test' } as Wallet, + monetize: false, + logRpcRequests: false + }) +} + +test('unstarted servers close idempotently and a completed reusable server can start without a configured host', async () => { + const fixture = await snapshotHttpFixture() + try { + const empty = bareServer() + await empty.close() + expect(empty.close()).toBe(Reflect.get(empty, 'closing')) + const listening = jest.spyOn(console, 'log').mockImplementation(() => undefined) + empty.start() + await once(empty.server, 'listening') + expect(listening).toHaveBeenCalledWith('WalletStorageServer listening at http://localhost:0') + expect(empty.server.listening).toBe(true) + await empty.close() + empty.start() + await once(empty.server, 'listening') + await empty.close() + expect(empty.server.listening).toBe(false) + } finally { + await fixture.close() + } +}) + +test.each(['ERR_SERVER_NOT_RUNNING', 'EIO'])('close waits for the real callback outcome %s', async code => { + const server = bareServer() + let complete!: (error?: NodeJS.ErrnoException) => void + server.server = { + close: jest.fn((callback: typeof complete) => { + complete = callback + return server.server + }) + } + let settled = false + const pending = server.close() + const observed = pending.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + const error = Object.assign(new Error('fixture listener close failure'), { code }) + complete(error) + if (code === 'ERR_SERVER_NOT_RUNNING') await expect(pending).resolves.toBeUndefined() + else { + await expect(pending).rejects.toBe(error) + expect(() => server.start()).toThrow('closing') + } + await observed +}) + +test('a capture cleanup rejection remains observable after the HTTP drain finishes', async () => { + const server = bareServer() + const http = gate() + const cleanup = new Error('fixture physical reader cleanup failed') + let finishHttp!: () => void + server.server = { + close: jest.fn((callback: () => void) => { + finishHttp = callback + http.resolve() + }) + } + Reflect.set(server, 'snapshotArchives', { close: () => Promise.reject(cleanup) }) + let settled = false + const pending = server.close() + const observed = pending.catch(() => { + settled = true + }) + await http.promise + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + finishHttp() + await expect(pending).rejects.toBe(cleanup) + await observed + await expect(server.close()).rejects.toBe(cleanup) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts index 5cc835708..432f68f3f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts @@ -44,8 +44,8 @@ test('generated creation/retry/close schedules preserve request identity, atomic fc.integer({ min: 0, max: 13 }), fc.integer({ min: 0, max: 3 }), fc.uint8Array({ minLength: 1, maxLength: 16 }), - fc.boolean(), - async (stage, prefix, retries, bytes, fail) => { + fc.constantFrom('failed' as const, 'closed' as const, 'resource-limited' as const), + async (stage, prefix, retries, bytes, terminalState) => { const identityKey = '02' + bytes[0].toString(16).padStart(64, '0') const other = '03' + '11'.repeat(32) const notAfter = Date.now() + 300000 @@ -99,10 +99,10 @@ test('generated creation/retry/close schedules preserve request identity, atomic await replacement.close(other, requestId) await expect(replacement.status(other, requestId)).rejects.toThrow('unavailable') expect((await db('snapshot_archive_capacity').first()).archives).toBe(1) - await replacement.close(identityKey, requestId, fail ? 'failed' : 'closed') + await replacement.close(identityKey, requestId, terminalState) await requests.close(identityKey, requestId) const terminal = (await requests.claim(identityKey, request)).receipt - expect(terminal.state).toBe(fail ? 'failed' : 'closed') + expect(terminal.state).toBe(terminalState) expect(terminal.archiveId).toBeUndefined() expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) expect(await db('snapshot_archives')).toHaveLength(0) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.test.ts new file mode 100644 index 000000000..19689d51a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.test.ts @@ -0,0 +1,98 @@ +import { SnapshotArchiveTransport } from './SnapshotArchiveTransport' +import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { fixture, expected, now } from '../../../../test/utils/snapshotArchiveDirectoryFixtures' + +const fields = { version: 1 as const, nonce: 'a'.repeat(64), notAfter: now + 1000, maxBytes: 32768 } +const request = { ...fields, requestId: snapshotArchiveRequestId(fields) } +const source = () => { + const { directory, payloads, binding } = fixture() + const offer = { + version: 1 as const, + serverTime: now, + sourceStorageIdentityKey: expected.sourceStorageIdentityKey, + sourceSchema: binding.sourceSchema, + chain: 'test' as const + } + const ready = { + version: 1 as const, + requestId: request.requestId, + expiresAt: request.notAfter, + state: 'ready' as const, + archiveId: directory.archiveId, + digest: directory.digest + } + const rpc = jest.fn(async (method: string, params: unknown[], _signal?: AbortSignal): Promise => { + const input = params[0] as Record + if (method === 'getSnapshotArchiveOffer') return offer + if (method === 'startSnapshotArchive' || method === 'getSnapshotArchiveStatus') return ready + if (method === 'getSnapshotArchiveDirectory') return directory + if (method === 'readSnapshotArchivePage') { + const i = input.sequence as number + return { ...directory.receipts[i], bytes: payloads[i] } + } + return true + }) + const transport = new SnapshotArchiveTransport(rpc, expected.identityKey, expected.sourceStorageIdentityKey, 'test') + return { transport, rpc, ready, offer, directory, payloads } +} + +test('auth transport binds exact immutable request/root/profile and forwards cancellation for each operation', async () => { + const { transport, rpc, ready, offer, payloads } = source() + const signal = new AbortController().signal + expect(await transport.offer(signal)).toEqual(offer) + expect(await transport.start(request, signal)).toEqual(ready) + expect(await transport.status(request, signal)).toEqual(ready) + const verified = await transport.directory(ready, offer, now, signal) + expect(await transport.page(verified, 12, signal)).toEqual(payloads[12]) + await transport.cancel(request.requestId, signal) + expect(rpc.mock.calls).toHaveLength(6) + for (const [, params, callSignal] of rpc.mock.calls) { + expect(params[0]).toMatchObject({ version: 1, identityKey: expected.identityKey }) + expect(callSignal).toBe(signal) + } + expect((rpc.mock.calls[1][1][0] as { request: unknown }).request).not.toBe(request) +}) + +test('declines malformed local arguments before network calls', async () => { + const { transport, rpc, ready, offer } = source() + expect(() => new SnapshotArchiveTransport(rpc, '', 'source', 'test')).toThrow() + await expect(transport.start({ ...request, maxBytes: 0 })).rejects.toThrow() + await expect(transport.status({ ...request, nonce: '' })).rejects.toThrow() + await expect(transport.cancel('')).rejects.toThrow() + for (const receipt of [ + { ...ready, state: 'building' as const }, + { ...ready, archiveId: undefined }, + { ...ready, digest: undefined } + ]) { + await expect(transport.directory(receipt, offer, now)).rejects.toThrow('not ready') + } + expect(rpc).not.toHaveBeenCalled() + const verified = await transport.directory(ready, offer, now) + rpc.mockClear() + for (const sequence of [-1, 0.1, 13, NaN]) + await expect(transport.page(verified, sequence)).rejects.toThrow('sequence') + expect(rpc).not.toHaveBeenCalled() +}) + +test('refuses changed expiry, root, profile, sequence and payload even when the server sends a success envelope', async () => { + const { transport, rpc, ready, offer, directory, payloads } = source() + await expect(transport.directory({ ...ready, expiresAt: ready.expiresAt + 1 }, offer, now)).rejects.toThrow( + 'expiry changed' + ) + await expect(transport.directory({ ...ready, digest: '0'.repeat(64) }, offer, now)).rejects.toThrow( + 'Invalid snapshot archive directory' + ) + const verified = await transport.directory(ready, offer, now) + rpc.mockResolvedValueOnce({ ...directory.receipts[1], bytes: payloads[1] }) + await expect(transport.page(verified, 0)).rejects.toThrow() + rpc.mockResolvedValueOnce({ ...directory.receipts[0], bytes: new Uint8Array([0]) }) + await expect(transport.page(verified, 0)).rejects.toThrow() + rpc.mockResolvedValueOnce(false) + await expect(transport.cancel(request.requestId)).rejects.toThrow('cancellation receipt') + rpc.mockResolvedValueOnce({ ...offer, sourceStorageIdentityKey: 'changed' }) + await expect(transport.offer()).rejects.toThrow() + rpc.mockResolvedValueOnce({ ...ready, requestId: '0'.repeat(64) }) + await expect(transport.start(request)).rejects.toThrow() + rpc.mockRejectedValueOnce(new Error('network failure')) + await expect(transport.status(request)).rejects.toThrow('network failure') +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts new file mode 100644 index 000000000..fe949823a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts @@ -0,0 +1,112 @@ +import { + verifySnapshotArchiveDirectory, + verifySnapshotArchivePage, + type VerifiedSnapshotArchiveDirectory +} from './SnapshotArchiveDirectory' +import { + parseSnapshotArchiveRequest, + type SnapshotArchiveRequest, + type SnapshotArchiveRequestReceipt +} from './SnapshotArchiveRequest' +import { + parseSnapshotArchiveRpcInput, + validateSnapshotArchiveOffer, + validateSnapshotArchiveRequestReceipt, + type SnapshotArchiveMethod, + type SnapshotArchiveOffer +} from './SnapshotArchiveProtocol' + +export type SnapshotArchiveRpcCall = ( + method: SnapshotArchiveMethod, + params: unknown[], + signal?: AbortSignal +) => Promise + +/** Bounded authenticated transport; a verified row reader is a separate layer. */ +export class SnapshotArchiveTransport { + constructor( + private readonly rpc: SnapshotArchiveRpcCall, + private readonly identityKey: string, + private readonly sourceStorageIdentityKey: string, + private readonly chain: 'main' | 'test' + ) { + parseSnapshotArchiveRpcInput('getSnapshotArchiveOffer', [{ version: 1, identityKey }]) + } + + private async call( + method: SnapshotArchiveMethod, + fields: Record, + signal?: AbortSignal + ): Promise { + const params = [{ version: 1, identityKey: this.identityKey, ...fields }] + parseSnapshotArchiveRpcInput(method, params) + return await this.rpc(method, params, signal) + } + + async offer(signal?: AbortSignal): Promise> { + return validateSnapshotArchiveOffer( + await this.call('getSnapshotArchiveOffer', {}, signal), + this.sourceStorageIdentityKey, + this.chain + ) + } + + async start(input: SnapshotArchiveRequest, signal?: AbortSignal): Promise> { + const request = parseSnapshotArchiveRequest(input) + return validateSnapshotArchiveRequestReceipt(await this.call('startSnapshotArchive', { request }, signal), request) + } + + async status(input: SnapshotArchiveRequest, signal?: AbortSignal): Promise> { + const request = parseSnapshotArchiveRequest(input) + return validateSnapshotArchiveRequestReceipt( + await this.call('getSnapshotArchiveStatus', { requestId: request.requestId }, signal), + request + ) + } + + async directory( + receipt: Readonly, + offer: Readonly, + now: number, + signal?: AbortSignal + ): Promise { + if (receipt.state !== 'ready' || receipt.archiveId === undefined || receipt.digest === undefined) + throw new TypeError('Snapshot archive is not ready') + const archiveId = receipt.archiveId + const digest = receipt.digest + const expiresAt = receipt.expiresAt + const sourceSchema = offer.sourceSchema + const value = await this.call('getSnapshotArchiveDirectory', { archiveId }, signal) + const verified = verifySnapshotArchiveDirectory( + value, + { + identityKey: this.identityKey, + chain: this.chain, + sourceStorageIdentityKey: this.sourceStorageIdentityKey, + archiveId, + digest, + sourceSchema + }, + now + ) + if (verified.manifest.expiresAt !== expiresAt) throw new TypeError('Snapshot archive expiry changed') + return verified + } + + async page(directory: VerifiedSnapshotArchiveDirectory, sequence: number, signal?: AbortSignal): Promise { + if (!Number.isSafeInteger(sequence) || sequence < 0 || sequence >= directory.receipts.length) + throw new TypeError('Invalid snapshot archive page sequence') + const receipt = directory.receipts[sequence] + const value = await this.call( + 'readSnapshotArchivePage', + { archiveId: directory.manifest.archiveId, sequence }, + signal + ) + return verifySnapshotArchivePage(value, receipt) + } + + async cancel(requestId: string, signal?: AbortSignal): Promise { + if ((await this.call('cancelSnapshotArchive', { requestId }, signal)) !== true) + throw new TypeError('Invalid snapshot archive cancellation receipt') + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureSnapshotArchiveSource.ts index a322823dd..f55fa4afb 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/captureSnapshotArchiveSource.ts @@ -40,7 +40,10 @@ function packedRows(rows: object[]): Array> { ) } -export type SnapshotArchiveCaptureStore = Pick +export type SnapshotArchiveCaptureStore = Pick & { + /** The initiating error allows a durable request owner to retain a bounded failure classification. */ + close: (identityKey: string, archiveId: string, error?: unknown) => Promise +} /** Shared ordered capture; source.close must finish physical ownership cleanup before sealing. */ export async function captureSnapshotArchiveSource( @@ -110,7 +113,7 @@ export async function captureSnapshotArchiveSource( } catch (error) { // Preserve the initiating error; interrupted cleanup keeps its reservation // and is recovered by reap rather than exposing a partial result. - if (writer !== undefined) await store.close(identityKey, writer.archiveId).catch(() => undefined) + if (writer !== undefined) await store.close(identityKey, writer.archiveId, error).catch(() => undefined) throw error } finally { if (!closed) await source.close().catch(() => undefined) diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts new file mode 100644 index 000000000..cd494d1e2 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { once } from 'node:events' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import type { Wallet } from '../../src/Wallet' +import { StorageKnex } from '../../src/storage/StorageKnex' +import { StorageProvider } from '../../src/storage/StorageProvider' +import { StorageServer, type WalletStorageServerOptions } from '../../src/storage/remoting/StorageServer' +import { seedArchiveClosure } from './snapshotArchiveFixtures' + +export function gate() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +export async function snapshotHttpFixture(snapshotSync = true) { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-http-')) + const key = PrivateKey.fromRandom() + const identityKey = key.toPublicKey().toString() + const wallet = new ProtoWallet(key) + const serverKey = PrivateKey.fromRandom() + const serverWallet = Object.assign(new ProtoWallet(serverKey), { chain: 'test' }) as unknown as Wallet + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + snapshotSync, + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + }) + const servers: StorageServer[] = [] + const close = async () => { + await Promise.all(servers.map(server => server.close())) + await storage.destroy() + await rm(directory, { recursive: true, force: true }) + } + try { + await storage.knex.raw('PRAGMA journal_mode = WAL') + await storage.migrate('HTTP snapshot source', 'http-snapshot-source') + await storage.makeAvailable() + const { user } = await storage.findOrInsertUser(identityKey) + const { user: other } = await storage.findOrInsertUser(PrivateKey.fromRandom().toPublicKey().toString()) + await seedArchiveClosure(storage, user.userId, other.userId) + const serve = async (options: Partial = {}) => { + const server = new StorageServer(storage, { + port: 0, + host: '127.0.0.1', + wallet: serverWallet, + monetize: false, + logRpcRequests: false, + ...options + }) + servers.push(server) + server.start() + if (!server.server.listening) await once(server.server, 'listening') + const address = server.server.address() + if (address === null || typeof address === 'string') throw new Error('Fixture listener did not bind') + return { server, url: `http://127.0.0.1:${address.port}` } + } + return { storage, identityKey, wallet, serverIdentityKey: serverKey.toPublicKey().toString(), serve, close } + } catch (error) { + await close() + throw error + } +} diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index f0d3a7d85..fd9f42c2d 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 40) + assert.equal(result.summary.propertySuites, 41) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 37) - assert.equal(result.summary.mutationTargets, 40) + assert.equal(result.summary.mutationTargets, 41) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 8347a65d3..20c22b957 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -135,8 +135,18 @@ schedule tests cover these persistence rules. The local service controller now integrates the existing owned source slot and shared capture path, reserves before pool acquisition and drains cancellation/shutdown before releasing its admission. Ready publication follows physical reader cleanup; failed cleanup fences the -controller, and completed archives survive replacement. Authenticated HTTP/client -integration and performance qualification under contention remain open. +controller, and completed archives survive replacement. + +The subsequent authenticated HTTP layer now negotiates the migrated WAL/MySQL +archive capability, returns prompt durable admission and accepts exact profile-bound +status/directory/page/cancel requests. Full and mobile client transports enforce +a dedicated response cap before parsing and bind immutable receipts, source +metadata and page inclusion. Actual synthetic HTTP tests cover lost admission, +replacement, cross-profile refusal and physical shutdown. Admission invokes +expiry cleanup; explicit resource-limit terminal status remains distinct from +other failures. This advances the S3 transport portion. The clock/cursor-compatible +remote row reader, sync-manager adoption, remote destination, portable semantics +and performance qualification under contention remain open. These checkpoints advance parts of S1/S2/P1/S4. They do not complete primary reconciliation, indexed identity/update predicates and commit-order high-water From e1c87df689d9e5af480406520766d2d22325442b Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 15:30:11 -0700 Subject: [PATCH 055/127] refactor(wallet): simplify snapshot HTTP dispatch and shutdown --- governance/mutation-testing/targets.mjs | 7 ++++- .../src/storage/remoting/StorageServer.ts | 26 ++++++++++------- .../archive/KnexSnapshotArchiveService.ts | 5 ++-- .../archive/SnapshotArchiveProtocol.ts | 28 +++++++++++-------- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index f346ca17c..f73411494 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -553,9 +553,14 @@ export function buildMutationTargets(repositoryRoot) { ], [ 'src/storage/remoting/StorageServer.ts', - 'const snapshotArchive = await', + '...(await this.snapshotArchiveSettings())', 'this.finishRpcLogging(logger, result)' ], + [ + 'src/storage/remoting/StorageServer.ts', + 'private async snapshotArchiveSettings(', + 'private async dispatchSyncTransfer(' + ], [ 'src/storage/remoting/StorageServer.ts', 'private createSnapshotArchiveRpc(', diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts index e556c6301..9aee927f3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts @@ -30,7 +30,7 @@ import type { PaymentReplayStore } from '@bsv/payment-express-middleware' import { Options as RateLimitOptions, rateLimit } from 'express-rate-limit' import { Wallet } from '../../Wallet' import { StorageProvider } from '../StorageProvider' -import { WERR_INTERNAL, WERR_NOT_ACTIVE, WERR_UNAUTHORIZED } from '../../sdk/WERR_errors' +import { WERR_INTERNAL, WERR_INVALID_OPERATION, WERR_NOT_ACTIVE, WERR_UNAUTHORIZED } from '../../sdk/WERR_errors' import { AuthId, SyncChunk } from '../../sdk/WalletStorage.interfaces' import { EntityTimeStamp } from '../../sdk/types' import { validateDate, validateEntity, validateEntities, validateSyncChunkEntities } from './entityValidationHelpers' @@ -74,7 +74,6 @@ import { snapshotArchiveResponseBytes, type SnapshotArchiveMethod } from '../snapshot/archive/SnapshotArchiveProtocol' -import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' const storageRpcMethods = new Set([ 'abortAction', @@ -659,11 +658,14 @@ export class StorageServer { const logObj = this.createRpcLog(req, method, id, params) try { this.enforceRpcRequestBudgets(method, params) - const dispatch = snapshotArchiveMethods.includes(method as SnapshotArchiveMethod) - ? await this.dispatchSnapshotArchive(method as SnapshotArchiveMethod, params, req, useBinary, id) - : method.endsWith('SyncTransfer') || method.endsWith('SyncTransferPart') - ? await this.dispatchSyncTransfer(method, params, req) - : await this.dispatchRpcCall(method, params, req, logObj, rpcSpan) + let dispatch: RpcDispatchResult + if (snapshotArchiveMethods.includes(method as SnapshotArchiveMethod)) { + dispatch = await this.dispatchSnapshotArchive(method as SnapshotArchiveMethod, params, req, useBinary, id) + } else if (method.endsWith('SyncTransfer') || method.endsWith('SyncTransferPart')) { + dispatch = await this.dispatchSyncTransfer(method, params, req) + } else { + dispatch = await this.dispatchRpcCall(method, params, req, logObj, rpcSpan) + } if (!dispatch.found) { return this.sendRpc( res, @@ -958,10 +960,9 @@ export class StorageServer { result = { ...result, syncCheckpointVersion: 1, - ...(this.syncTransfers == null ? {} : { syncTransfer: this.syncTransfers.capabilities }) + ...(this.syncTransfers == null ? {} : { syncTransfer: this.syncTransfers.capabilities }), + ...(await this.snapshotArchiveSettings()) } - const snapshotArchive = await this.snapshotArchives?.capabilities() - if (snapshotArchive !== undefined) result.snapshotArchive = snapshotArchive } this.finishRpcLogging(logger, result) return { found: true, result } @@ -972,6 +973,11 @@ export class StorageServer { } } + private async snapshotArchiveSettings(): Promise> { + const snapshotArchive = await this.snapshotArchives?.capabilities() + return snapshotArchive === undefined ? {} : { snapshotArchive } + } + private async dispatchSyncTransfer(method: string, params: any[], req: Request): Promise { const transfers = this.syncTransfers if (transfers == null) return { found: false } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts index 51fbd6ea2..9a2f9f597 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts @@ -184,7 +184,8 @@ export class KnexSnapshotArchiveService { return this.closing } - private async closedWithoutCapture(): Promise { - if (this.cleanupFailure !== undefined) throw this.cleanupFailure.error + private closedWithoutCapture(): Promise { + if (this.cleanupFailure !== undefined) return Promise.reject(this.cleanupFailure.error) + return Promise.resolve() } } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts index 7628393d8..5de0b9412 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts @@ -79,23 +79,29 @@ export type SnapshotArchiveRpcInput = | { method: 'getSnapshotArchiveDirectory'; identityKey: string; archiveId: string } | { method: 'readSnapshotArchivePage'; identityKey: string; archiveId: string; sequence: number } +function requestFields(method: SnapshotArchiveMethod): string[] { + switch (method) { + case 'startSnapshotArchive': + return ['request'] + case 'getSnapshotArchiveStatus': + case 'cancelSnapshotArchive': + return ['requestId'] + case 'getSnapshotArchiveDirectory': + return ['archiveId'] + case 'readSnapshotArchivePage': + return ['archiveId', 'sequence'] + default: + return [] + } +} + /** Exact method-specific data only; ownership tokens have no wire representation. */ export function parseSnapshotArchiveRpcInput( method: SnapshotArchiveMethod, params: unknown[] ): SnapshotArchiveRpcInput { if (params.length !== 1) invalid() - const extras = - method === 'startSnapshotArchive' - ? ['request'] - : method === 'getSnapshotArchiveStatus' || method === 'cancelSnapshotArchive' - ? ['requestId'] - : method === 'getSnapshotArchiveDirectory' - ? ['archiveId'] - : method === 'readSnapshotArchivePage' - ? ['archiveId', 'sequence'] - : [] - const input = record(params[0], ['version', 'identityKey', ...extras]) + const input = record(params[0], ['version', 'identityKey', ...requestFields(method)]) if (input.version !== 1 || typeof input.identityKey !== 'string' || !/^(02|03)[0-9a-f]{64}$/.test(input.identityKey)) invalid() const identityKey = input.identityKey From 7539b7109de2c2cffa220b7a9df622ab807ab269 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 16:44:27 -0700 Subject: [PATCH 056/127] fix(docs): update Mermaid DOMPurify resolution and license inventory --- docs/reference/dependency-policy.md | 12 ++++++++++++ governance/docs-site-bundled-materials.json | 2 +- governance/third-party-materials.json | 2 +- pnpm-lock.yaml | 8 ++++---- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index 1c470b98a..819a736b9 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -108,6 +108,18 @@ notes and necessity, runtime/build/peer compatibility, lockfile deduplication, audit and CodeQL results, package and consumer tests, bundle/performance impact, and affected public versions. +The docs-site Mermaid graph selects DOMPurify 3.4.16 instead of 3.4.13 for +[GHSA-p98j-92pf-mc4p](https://github.com/advisories/GHSA-p98j-92pf-mc4p). +The reviewed [3.4.16 release](https://github.com/cure53/DOMPurify/releases/tag/3.4.16) +fits Mermaid 11.16.1's existing `^3.3.3` range and exceeds the seven-day release +age. pnpm regenerates only that package resolution, integrity and existing graph +reference; all importers, manifests and unrelated resolutions remain unchanged. +The governed bundle inventory records the same version, with its license +expression and license-file hash unchanged. This private documentation dependency does not change wallet runtime packages, +public APIs or candidate versions. Frozen installation, root checks, docs tests +and a built-site browser check qualify the ordinary Mermaid consumer. No service +or package is deployed by this source change. + ## Supply-chain controls `pnpm-workspace.yaml` is the source of truth for installation controls: diff --git a/governance/docs-site-bundled-materials.json b/governance/docs-site-bundled-materials.json index b18f4e257..1f00a542c 100644 --- a/governance/docs-site-bundled-materials.json +++ b/governance/docs-site-bundled-materials.json @@ -497,7 +497,7 @@ }, { "name": "dompurify", - "version": "3.4.13", + "version": "3.4.16", "kind": "vite", "packageLicense": "(MPL-2.0 OR Apache-2.0)", "licenseExpression": "Apache-2.0", diff --git a/governance/third-party-materials.json b/governance/third-party-materials.json index ef1a53de4..a11bbbf5c 100644 --- a/governance/third-party-materials.json +++ b/governance/third-party-materials.json @@ -26,7 +26,7 @@ { "id": "docs-site-bundled-materials", "path": "governance/docs-site-bundled-materials.json", - "sha256": "90468e6f7ecd26ec72a3d5622a074e5ec7995395496d612f506c38efb9f6d06f" + "sha256": "431313ebc69f0d962dcd30aa2737e8386e9667919ea5de222d4479d6e208767b" }, { "id": "license-continuity", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea972ffd2..a83dfce59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5471,8 +5471,8 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dompurify@3.4.13: - resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} @@ -12475,7 +12475,7 @@ snapshots: dom-accessibility-api@0.6.3: {} - dompurify@3.4.13: + dompurify@3.4.16: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -14222,7 +14222,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.21 - dompurify: 3.4.13 + dompurify: 3.4.16 es-toolkit: 1.50.0 katex: 0.16.47 khroma: 2.1.0 From 474855618eb361549981ff7e3b28e17f7cd51d47 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 17:47:04 -0700 Subject: [PATCH 057/127] docs: review wallet manual inventory and correct funded side effects --- .../wallet-manual-review-2026-10-01.md | 72 +++++++++++++++++++ .../wallet-toolbox-manual-suites.json | 20 +++--- .../wallet-toolbox-suite-disposition.md | 3 +- 3 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 governance/test-quality/wallet-manual-review-2026-10-01.md diff --git a/governance/test-quality/wallet-manual-review-2026-10-01.md b/governance/test-quality/wallet-manual-review-2026-10-01.md new file mode 100644 index 000000000..05964d8d2 --- /dev/null +++ b/governance/test-quality/wallet-manual-review-2026-10-01.md @@ -0,0 +1,72 @@ +# Wallet manual inventory review — 2026-10-01 + +Owner: `ts-stack-maintainers` + +Review by: 2026-10-08 + +Source: `7539b7109de2c2cffa220b7a9df622ab807ab269` (the reviewed manual-suite bytes are unchanged in the reader working tree). + +This is a static inventory and disposition review of all 30 wallet `*.man.test.ts` and `*.live.test.ts` suites. The tracked/discovered set matches the exact case-insensitive inventory with no missing or duplicate paths. Every retained suite has an explicit assertion oracle; the existing extraction destinations and retain-as-test decisions remain. No suite, assertion, skip, runtime input, required gate or execution classification rule is removed. + +No manual, public-network, remote-state or funded suite was executed for this review. This record does not establish runtime success, provider readiness, restored funds, complete #544 qualification, or permission to run those operations. Existing skipped manual cases remain unexecuted. The next review is bounded to seven days. + +## Corrected side-effect descriptions + +Four entries understated observable operations in their existing source; all four now use `funded-state`: + +- `src/storage/remoting/__test/StorageClient.man.test.ts` selects mainnet and creates/signs actions with broadcast enabled. The bounded loop is still a funded exercise. +- `src/wab-client/__tests/WABClient.man.test.ts` requests a faucet payment and asserts its amount, alongside authentication and user deletion. A localhost server address does not establish disposable funds. +- `test/Wallet/local/localWallet.man.test.ts` calls `createOneSatTestOutput` with the configured test-chain identity, changes the active cloud/local store and copies backups. The helper creates actions and sends its no-send batch. The local filename does not make the whole suite disposable. +- `test/WalletClient/LocalKVStore.man.test.ts` uses a mainnet setup and an attached WalletClient. SDK `LocalKVStore.set/remove` creates and spends one-satoshi tokens through wallet actions, so this is not disposable key-value storage. + +The other 26 side-effect records remain. Fixed broadcaster timeout requests, chain/proof queries and public header subscriptions remain public-network tests; administrative statistics remain read-only remote assertions; backup-example creation remains a local-artifact operation with configured remote reads. Existing remote-state classifications cover profile/certificate/spec-operation mutations. These categories describe review scope, not an execution allowlist. + +## Retained assertion oracles + +Service suites assert exact transaction outcomes, error classifications, proof inclusion, bounded latency/case counts or provider/header relationships. Storage suites assert profile identity, availability, backup registration or synchronization results. Wallet action suites assert BEEF/cardinality, action outcomes, proof/signature checks and remapped identities. Certificate suites assert stored/encrypted fields and recovered cleartext. LocalKVStore asserts idempotent outpoints, retrieved values, removed token counts and empty baskets. The live Chaintracks suite compares independently requested tips, headers and stream events. These oracles justify keeping the suites as tests; they are not evidence that a current external service passes. + +## Exact reviewed source inventory + +Paths are relative to `packages/wallet/wallet-toolbox/`. SHA-256 binds the reviewed bytes without copying credentials, transaction fixtures or replay payloads. + +| Suite | Side effects after review | SHA-256 | +| --------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------ | +| `src/services/__tests/ARC.man.test.ts` | `funded-state` | `651ae1bf5c018cf136a421465df458ff078492e5ffa05fa4a6468bfecc9d60ba` | +| `src/services/__tests/ARC.timeout.man.test.ts` | `public-network` | `6bb23b55eef1b44a3ec5cecc6e98271c9cd71d61d373a8ad2987a4c9001fc4f9` | +| `src/services/__tests/ArcGorillaPool.man.test.ts` | `funded-state` | `389fb06ebc0d0e2988599903087f1a9b2ad678f37c6e0506a09861a105412a49` | +| `src/services/__tests/Arcade.man.test.ts` | `public-network` | `53dabf49dc9b52f168ba9c75b79417d73a260f7ea67d8832d4d673fa8a88db0d` | +| `src/services/__tests/ArcadeMainnet.man.test.ts` | `funded-state` | `2353db932b9141dbb5a14b9a1f52ab70fcddaef1684b7ad00983d2f3fc1ccea8` | +| `src/services/__tests/ArcadeProof.man.test.ts` | `public-network` | `5060ad7c87b210b04b8607317d4c2020f40351753c989490608dbed5495f33ad` | +| `src/services/__tests/StorageE2E.man.test.ts` | `funded-state` | `f91208b3847e89af15eb015892b38caccc07c8a71c72536c6fb143c80a8c7a4c` | +| `src/services/__tests/postBeef.man.test.ts` | `funded-state` | `cb31d8c772003d0c456812b6a9a00ce842d3c99cdc7bfb61782b0a789dccf22e` | +| `src/services/chaintracker/chaintracks/Ingest/__tests/WhatsOnChainServices.man.test.ts` | `public-network` | `5bf9f5a89c9dd211cb5b7067cfe9cbdb21ce01171c36520f174836547cb30251` | +| `src/services/chaintracker/chaintracks/__tests/Chaintracks.man.test.ts` | `public-network` | `edd7ea29579208caedaaa485455e6c2950dd2b535c8f670769a656d90a0a7ed9` | +| `src/services/chaintracker/chaintracks/util/__tests/ChaintracksFetch.man.test.ts` | `public-network` | `2678082fc38c5b50d18a610665d42d2701a1fdd71210df56416db732042ba985` | +| `src/services/providers/__tests/WhatsOnChain.man.test.ts` | `public-network` | `0ee5133324f2efb3ab8daeaf71b58b50fb50d90850e92432f7f9156c828913f6` | +| `src/storage/__test/adminStats.man.test.ts` | `read-only-remote` | `d3ad0c5a9848c6b149afa0c1622c8f669781e09a30d43e46803dec152f736eaa` | +| `src/storage/remoting/__test/StorageClient.man.test.ts` | `funded-state` | `8a62b93ed38dc695cf823e43790e3f2f485338bf1b6e8dd5db4d8f6778916824` | +| `src/wab-client/__tests/WABClient.man.test.ts` | `funded-state` | `147e0ff852c83e44279266f96f26031279ea89e1545c3cf74fc6518130099476` | +| `test/Wallet/StorageClient/storageClient.man.test.ts` | `remote-state` | `b636029a0a70b4bf88e15a89dc45d95a378e1d7c2b7dc58ded77a742da2659a2` | +| `test/wallet/action/createActionToGenerateBeefs.man.test.ts` | `funded-state` | `8f5629079a37335fedd03303bc001d9d52e1ac6bca9b05ff16c0a449b0e775d3` | +| `test/Wallet/action/internalizeAction.a.man.test.ts` | `funded-state` | `4eb1c876b537d06b0caf4e8b60e887f5f7b99e555008d7999f02689cd8f695e2` | +| `test/Wallet/certificate/acquireCertificate.man.test.ts` | `remote-state` | `ec07917964ec8d0c3b706f7b3ebe637d6d7874e41f70a94082dbbd4b8dc9a698` | +| `test/Wallet/live/walletLive.man.test.ts` | `funded-state` | `16b13b9683460cd6eef44b1362778b5a91c155f6046e52bb6d8cd7f01f67eb1b` | +| `test/Wallet/local/localWallet.man.test.ts` | `funded-state` | `c4562e5319edb63933d6dd3c86e9300eac6dd6f0e8f224c24e7f10cc4acc9090` | +| `test/Wallet/local/localWallet2.man.test.ts` | `funded-state` | `a8dccb910a93c4f010655877f2b4d765cf9de663263fbdc9a7ecdd96af08ae83` | +| `test/Wallet/signAction/mountaintop.man.test.ts` | `funded-state` | `caff05a77fce77ede7567e3e3949ffae7a2fab3ee74466cd048db1adfe4e13f4` | +| `test/Wallet/specOps/specOps.man.test.ts` | `remote-state` | `fb132ce9e7e92904f67e33ff760e54dc1498846eff4923ccf083e65d87d7911a` | +| `test/WalletClient/LocalKVStore.man.test.ts` | `funded-state` | `ab65d322ae9a2e32c6367116da38d3240f4f45ec9864299c5a471aaaeb44013b` | +| `test/WalletClient/WERR.man.test.ts` | `remote-state` | `d64e2164e87d0cdb1339dac516cf8d9c703b784e12e4364d0c69ec6e781512a4` | +| `test/WalletClient/staging.auth.man.test.ts` | `remote-state` | `b86a0891ed7a6bd1fe3ce9805370bd5afea32f25a123530f3bd78d1ebc58cd2b` | +| `test/examples/backup.man.test.ts` | `local-artifact` | `273fdc5044979c1f0e56d311c9198b832ccafb7757c8e63d73055b7e84dc8583` | +| `test/services/Services.man.test.ts` | `public-network` | `5b01e9d638d4116ef7a5267d888a760a28592381dd814f5d0e1fb865edd78cbb` | +| `src/services/chaintracker/chaintracks/__tests/GoChaintracksServiceClient.live.test.ts` | `public-network` | `bf4b4582d8e8496e056aa8eaaf82ea9687f835341c1ca500ccd0e843cd5e1b49` | + +Review totals: 14 funded-state, 1 local-artifact, 9 public-network, 1 read-only-remote, 5 remote-state. + +The nine test-governance regressions pass. With the separately reviewed mutation +date patch applied, real-date `pnpm test:governance` passes and reports all 30 +exact wallet dispositions, 32 total classified manual/live files and 42 actual +branch-local property/mutation targets. The mutation-policy date is owned by the +separately coordinated root review; it is not changed by this wallet inventory +review. diff --git a/governance/test-quality/wallet-toolbox-manual-suites.json b/governance/test-quality/wallet-toolbox-manual-suites.json index 529e85867..6b789f5d4 100644 --- a/governance/test-quality/wallet-toolbox-manual-suites.json +++ b/governance/test-quality/wallet-toolbox-manual-suites.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-30", + "lastReviewed": "2026-10-01", "owner": "ts-stack-maintainers", - "reviewBy": "2026-09-30", + "reviewBy": "2026-10-08", "suites": [ { "path": "packages/wallet/wallet-toolbox/src/services/__tests/ARC.man.test.ts", @@ -98,16 +98,16 @@ { "path": "packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.man.test.ts", "kind": "manual-integration", - "sideEffects": "remote-state", + "sideEffects": "funded-state", "disposition": "retain-as-test", - "purpose": "Validates bounded tagged-revision create/sign, availability, and recorded authentication replay behavior against explicitly configured storage." + "purpose": "Validates bounded funded mainnet create/sign and broadcast against a tagged storage revision, plus availability and recorded authentication behavior." }, { "path": "packages/wallet/wallet-toolbox/src/wab-client/__tests/WABClient.man.test.ts", "kind": "manual-integration", - "sideEffects": "remote-state", + "sideEffects": "funded-state", "disposition": "retain-as-test", - "purpose": "Validates WAB server information, phone authentication, faucet, linked methods, and user deletion." + "purpose": "Validates WAB information, phone authentication, a faucet payment, linked methods and user deletion against the configured server." }, { "path": "packages/wallet/wallet-toolbox/test/Wallet/StorageClient/storageClient.man.test.ts", @@ -147,9 +147,9 @@ { "path": "packages/wallet/wallet-toolbox/test/Wallet/local/localWallet.man.test.ts", "kind": "manual-integration", - "sideEffects": "disposable-state", + "sideEffects": "funded-state", "disposition": "retain-as-test", - "purpose": "Validates local wallet monitoring, action creation, synchronization, active-store changes, and backup." + "purpose": "Validates funded test-chain action creation, monitoring, cloud/local active-store changes, synchronization and backup using the configured identity." }, { "path": "packages/wallet/wallet-toolbox/test/Wallet/local/localWallet2.man.test.ts", @@ -175,9 +175,9 @@ { "path": "packages/wallet/wallet-toolbox/test/WalletClient/LocalKVStore.man.test.ts", "kind": "manual-integration", - "sideEffects": "disposable-state", + "sideEffects": "funded-state", "disposition": "retain-as-test", - "purpose": "Validates local key-value persistence for Wallet and WalletClient implementations." + "purpose": "Validates one-satoshi key-value token creation, replacement and removal through a configured mainnet Wallet and attached WalletClient." }, { "path": "packages/wallet/wallet-toolbox/test/WalletClient/WERR.man.test.ts", diff --git a/governance/test-quality/wallet-toolbox-suite-disposition.md b/governance/test-quality/wallet-toolbox-suite-disposition.md index f3ef0acfe..7940acf78 100644 --- a/governance/test-quality/wallet-toolbox-suite-disposition.md +++ b/governance/test-quality/wallet-toolbox-suite-disposition.md @@ -1,6 +1,7 @@ # Wallet Toolbox manual-suite disposition -Last reviewed: 2026-08-30 +Extraction reviewed: 2026-08-30 +Inventory reviewed: [2026-10-01](./wallet-manual-review-2026-10-01.md) Owner: `ts-stack-maintainers` This ledger records the manual-suite review that separates executable tests From b41decd00121f29b76090285fae45420b2d7baee Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 19:33:09 -0700 Subject: [PATCH 058/127] Checkpoint unadvertised snapshot reader for main reconciliation Preserve the server-issued reader foundation, strict packed rows, immutable retry and cursor integration while full qualification remains incomplete. Production reader advertisement stays disabled. Current focused reader and property suites pass; mutation and exact-head CI still require qualification. Include the declaration-only hash-wasm correction and strict consumers without runtime or SDK-range changes. --- .github/workflows/ci.yml | 2 +- .github/workflows/mutation-tests.yml | 2 +- docs/guides/wallet-sync-reliability.md | 53 +++ docs/reference/package-api-migrations.md | 84 ++--- .../mutation-testing/REVIEW-2026-10-01.md | 74 ++++ governance/mutation-testing/policy.json | 14 +- governance/mutation-testing/targets.mjs | 37 ++ governance/package-release-notes.json | 12 +- governance/test-quality/policy.json | 13 + packages/wallet/wallet-toolbox/CHANGELOG.md | 10 + packages/wallet/wallet-toolbox/package.json | 4 +- .../src/sdk/WalletStorage.interfaces.ts | 5 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 3 +- .../src/storage/WalletStorageManager.ts | 38 +- .../wallet-toolbox/src/storage/index.all.ts | 1 + .../src/storage/index.client.ts | 1 + .../src/storage/index.mobile.ts | 1 + .../src/storage/remoting/StorageClientBase.ts | 71 +++- .../snapshot/KnexSnapshotSyncDestination.ts | 6 +- .../storage/snapshot/RetainedReadSnapshot.ts | 3 +- .../snapshot/SnapshotCancelledError.ts | 4 + .../storage/snapshot/SnapshotCursor.test.ts | 84 +++++ .../src/storage/snapshot/SnapshotCursor.ts | 68 ++++ .../snapshot/SnapshotSync.integration.test.ts | 166 ++++++++- .../src/storage/snapshot/SnapshotSync.ts | 4 +- .../storage/snapshot/SnapshotSyncRows.test.ts | 18 + .../src/storage/snapshot/SnapshotSyncRows.ts | 2 + .../snapshot/SnapshotSyncSession.test.ts | 57 ++- .../storage/snapshot/WalletReadSnapshot.ts | 13 +- .../KnexSnapshotArchiveRequestStore.test.ts | 254 +++++++++++++ .../KnexSnapshotArchiveRequestStore.ts | 184 ++++++++- .../archive/KnexSnapshotArchiveRpc.ts | 47 ++- .../KnexSnapshotArchiveService.test.ts | 148 ++++++++ .../archive/KnexSnapshotArchiveService.ts | 72 +++- .../archive/RemoteSnapshotLease.test.ts | 303 +++++++++++++++ .../snapshot/archive/RemoteSnapshotLease.ts | 165 +++++++++ .../archive/RemoteSnapshotOpening.test.ts | 211 +++++++++++ .../archive/RemoteSnapshotPageReader.ts | 221 +++++++++++ .../RemoteSnapshotReader.property.test.ts | 68 ++++ .../archive/RemoteSnapshotReader.test.ts | 334 +++++++++++++++++ .../archive/RemoteSnapshotReaderHttp.test.ts | 350 ++++++++++++++++++ .../archive/RemoteSnapshotRows.test.ts | 340 +++++++++++++++++ .../snapshot/archive/RemoteSnapshotRows.ts | 327 ++++++++++++++++ .../archive/SnapshotArchiveAdmission.test.ts | 77 ++++ .../archive/SnapshotArchiveAdmission.ts | 60 +++ .../SnapshotArchiveProtocol.property.test.ts | 34 +- .../archive/SnapshotArchiveProtocol.test.ts | 39 +- .../archive/SnapshotArchiveProtocol.ts | 28 +- .../SnapshotArchiveReaderClient.test.ts | 113 ++++++ .../SnapshotArchiveReaderOffer.test.ts | 129 +++++++ .../archive/SnapshotArchiveReaderOffer.ts | 56 +++ .../SnapshotArchiveReaderRequest.test.ts | 134 +++++++ .../archive/SnapshotArchiveReaderRequest.ts | 119 ++++++ .../archive/SnapshotArchiveTransport.ts | 54 ++- .../SnapshotArchiveTransportFailure.test.ts | 68 ++++ .../SnapshotArchiveTransportFailure.ts | 38 ++ .../snapshot/archive/openRemoteSnapshot.ts | 98 +++++ .../snapshot/runSnapshotSyncSession.ts | 8 +- .../wallet-toolbox/src/utility/hashWasm.ts | 2 +- .../wallet-toolbox/test/consumer/hashWasm.cts | 28 ++ .../wallet-toolbox/test/consumer/hashWasm.mts | 28 ++ .../test/consumer/tsconfig.json | 13 + .../test/storage/snapshotArchiveMysql.cjs | 108 +++++- .../utils/remoteSnapshotReaderFixtures.ts | 115 ++++++ .../test/utils/snapshotArchiveHttpFixtures.ts | 4 +- scripts/ci-orchestration.test.mjs | 2 +- scripts/test-governance.test.mjs | 4 +- specs/wallet/sync-portability-program.md | 17 + 68 files changed, 5088 insertions(+), 162 deletions(-) create mode 100644 governance/mutation-testing/REVIEW-2026-10-01.md create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCancelledError.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCursor.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCursor.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotOpening.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotPageReader.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveAdmission.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveAdmission.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderClient.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderOffer.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderOffer.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderRequest.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderRequest.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/openRemoteSnapshot.ts create mode 100644 packages/wallet/wallet-toolbox/test/consumer/hashWasm.cts create mode 100644 packages/wallet/wallet-toolbox/test/consumer/hashWasm.mts create mode 100644 packages/wallet/wallet-toolbox/test/consumer/tsconfig.json create mode 100644 packages/wallet/wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6172bf9fc..c6f26b1d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -478,7 +478,7 @@ jobs: runs-on: ubuntu-latest # Preserve complete wallet snapshot campaigns within a bounded allowance. # Snapshot sync is partitioned after the unsplit campaign exceeded 90 minutes. - timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows"]'), matrix.target) && 90 || 45 }} + timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 1b040d343..757fa5fa4 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -80,7 +80,7 @@ jobs: runs-on: ubuntu-latest # Preserve complete wallet snapshot campaigns within a bounded allowance. # Snapshot sync is partitioned after the unsplit campaign exceeded 90 minutes. - timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows"]'), matrix.target) && 90 || 20 }} + timeout-minutes: ${{ contains(fromJSON('["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http"]'), matrix.target) && 90 || 20 }} permissions: contents: read strategy: diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index c9b40435d..ee9a1abd6 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -622,3 +622,56 @@ SQLite profiles, both client variants, lost admission acknowledgements, server replacement, profile isolation, rollback combinations, response limits and shutdown during opening. These fixtures do not establish deployed performance, physical mobile acceptance or completion of #544. + +## Remote row reader (unadvertised implementation) + +The client now implements a source-only `getSnapshotSync()` adapter that +negotiates the separate `snapshotArchiveReaderVersion: 1` setting when opened. +The server does not yet advertise that setting. Controlled HTTP fixtures opt in +to exercise the implementation; ordinary remote sync continues its existing path. +Old archive capability objects and all six legacy methods remain unchanged. +The new adapter refuses destination operations and does not imply portable +export, staged restore or a remote destination implementation. + +Reader offers retain bounded metadata without acquiring a source. An explicit +capacity refusal returns no request. An offered request uses version two and a +separate digest domain; admission requires that exact persisted offer and never +creates an absent request. Consequently cancellation can collect a successfully +released reader receipt immediately without allowing a delayed admission or the +legacy start method to recreate it. Failed and resource-limited receipts remain +observable until explicit cancellation or expiry. The existing four/profile, +64-total metadata limits and logical archive reservations are unchanged. + +Only a native-fetch rejection permits one retry of an immutable admission, +status, directory, page or cancellation request. An offer is never retried; +recovery neither renews the lease nor starts another capture. Authentication, +framing and RPC failures do not select that retry. +Cancellation recognizes the local signal and the SDK's explicit cancellation +code; concurrent independent errors remain failures. The client uses one fixed +wall/monotonic lease, one operation at a time and one private decoded frame. It +checks the complete directory, receipt and row representation before returning +packed rows, fresh dates and an additive version-one `cursor.archivePosition`. +The position identifies the archive, frame sequence and consumed row offset; it +also checks the original row keys. Table lookup does not fetch earlier tables +or reinterpret SQL collation as JavaScript ordering. + +Local destinations persist that optional position with the atomic row commit. +Preparation and progress callbacks receive detached cursor metadata, and an +acknowledgement with a missing or changed position is rejected. Legacy cursor +JSON is unchanged when the position is absent. The manager forwards cancellation +to source opening, returns committed progress only after successful cleanup and +does not switch to a different copy after an accepted remote source fails. + +Actual loopback HTTP fixtures cover full/mobile negotiation, every archived +table, original-source retention through replacement, native-fetch loss before +and after immutable requests, failed authentication and cancellation. Both +clients also sync over HTTP into an occupied local destination: cancellation +retains the committed archive position, a later view completes without duplicate +labels, and the active destination and unrelated profile remain unchanged. The generated +reader property varies frame and caller-page boundaries over at least 300 cases. +These are implementation checks, not completed performance or platform evidence. +In particular, a cancellation handled by another replica can fence durable +admission and staging without proving that the original replica has physically +drained its SQL reader. Owner recovery and bounded driver/query cleanup remain +open requirements before server advertisement and program completion. No claim +of a distributed physical-pool ceiling follows from logical quota accounting. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index d58872b4e..b05b904ba 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -537,8 +537,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -549,8 +549,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/mutation-testing/REVIEW-2026-10-01.md b/governance/mutation-testing/REVIEW-2026-10-01.md new file mode 100644 index 000000000..9f15e169b --- /dev/null +++ b/governance/mutation-testing/REVIEW-2026-10-01.md @@ -0,0 +1,74 @@ +# Mutation-policy inventory review: 2026-10-01 + +Owner: `ts-stack-maintainers`. Next inventory review: 2026-10-31. + +This review renews the ownership and inventory check. It does not qualify any +runtime campaign, manual suite, PR, release or deployed behavior. A mutation +campaign still needs its complete report and the exact candidate's applicable CI +gates. Failed, missing, cancelled and pending reports remain unfinished. + +The reviewed implementation baseline is +`ab5aa54493372503b27c0845085dc7a23dce40d2` on the UTXO runtime candidate. Its executable +registry contains 87 targets, exactly matching 87 registered property suites. +Every target's manifest, property file and authored mutation source exists. The +33 mutation policies already on main at +`69879c3ed27dbff8384b2f12a5ca24c939fa64c6` retain their exact risk, minimum score, +maximum uncovered count and maximum invalid count. Existing thresholds differ by +target; this review neither normalizes nor relaxes them. + +The complete governance evaluation at 2026-10-01 finds only the two elapsed review +dates: this mutation inventory and the separately owned Wallet Toolbox manual +inventory. It counts 1,143 required test files, 87 property suites, 87 executable +mutation targets, 32 classified manual/live files and 30 exact wallet manual-suite +dispositions. The latter inventory is not renewed by this change. Evaluation is +available through `evaluateTestGovernance` in `scripts/test-governance.mjs`; the +ordinary CLI retains its real-date checks without a date override. + +The governance and compiled-example regressions pass all 23 cases. They verify +complete disjoint source unions and identical full test selections for the action, +lookup session, root storage/codec, wallet recovery and lineage partitions; the +lookup-work extraction includes the complete shared implementation. The root HTTP +target includes both full runtime modules, its structural declarations and its +actual authentication/storage dependencies. No target, source range, test, +additional input or property registration is changed in this renewal. + +The reviewed configuration keeps Stryker 9.6.1, four workers, per-test coverage, +2,000 ms timeout allowance and factor three. The property budget remains at least +300 cases with seed 3242026; reports retain 30 days. The workflow deadline and +selection expressions, runner error handling, zero-uncovered/zero-invalid gates +and all source-selection regressions remain unchanged. Inventory validity is not +authority to defer or omit a required exact-head campaign. + +Coordination is recorded in BotBoard discussions 702, 703 and 713. The Wallet +Toolbox branch's independent 42-target inventory and the DID branch's additions +are not overwritten or claimed as integrated here. Future additions must preserve +the union when branches meet. The proposed root client remains outside this +87-target baseline; its registration requires actual implementation and its own +property and mutation evidence. This patch changes only the mutation review dates +and this review record; it does not authorize public-network, funded or manual +execution, merge, package publication or deployment. + +## Wallet #569 adoption review + +The isolated date patch above is adopted from +`1b711fca31ca1495bc4e11b2d3b3239d15ce7207`. Its two changed policy fields were +independently checked; the 87 baseline manifest/property references exist at the +recorded revision and all 33 main policies match exactly. That source review is +not a claim that the UTXO runtime branch or its target implementations have been +merged into #569. + +On #569, the working reader registry contains 42 actual executable/property +registrations. Its complete prior 41 policy entries, mutation source ranges and +regression-test selections are preserved, with one added HTTP regression file +and the complete ten-file reader target. Every target's manifest, property and +mutation source exists; 172 policy/source/property/workflow input files were +SHA-256-bound in the local review evidence. The new reader retains its critical +90% minimum and zero uncovered/invalid limits. Its mutation run remains pending. +The 338-suite wallet coverage run passes 4,095 cases with the unchanged skip and +782 stable source/test/config hashes; that does not substitute for mutation CI. + +The companion [wallet manual inventory review](../test-quality/wallet-manual-review-2026-10-01.md) +reviews all 30 retained suites without running them and corrects four understated +funded-operation labels. No source/test union, seed, worker, threshold or deadline +is changed by either inventory review. Hosted cancellations remain failures; +subsequent deadline corrections require their own evidence and coordination. diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index a92e83ef3..4cad363e3 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "lastReviewed": "2026-09-01", - "reviewBy": "2026-09-30", + "lastReviewed": "2026-10-01", + "reviewBy": "2026-10-31", "owner": "ts-stack-maintainers", "tool": { "package": "@stryker-mutator/core", @@ -137,6 +137,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-snapshot-remote-reader", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts", + "risk": "critical", + "boundary": "Wallet immutable remote snapshot row decoding, fixed archive cursors, bounded leases and exact admission recovery", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "overlay-linkage", "manifest": "packages/overlays/topics/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index f73411494..326dcfd21 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -581,6 +581,7 @@ export function buildMutationTargets(repositoryRoot) { 'jest.config.cjs', [ '/src/storage/snapshot/archive/SnapshotArchive*.test.ts', + '/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts', '/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts', '/src/storage/remoting/__test/BinaryJson.test.ts', '/src/storage/remoting/__test/KnexPaymentReplayStore.test.ts', @@ -602,6 +603,42 @@ export function buildMutationTargets(repositoryRoot) { } ) }, + 'wallet-snapshot-remote-reader': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts', + mutate: [ + 'src/storage/snapshot/archive/SnapshotArchiveReaderRequest.ts', + 'src/storage/snapshot/archive/SnapshotArchiveReaderOffer.ts', + 'src/storage/snapshot/archive/SnapshotArchiveAdmission.ts', + 'src/storage/snapshot/archive/SnapshotArchiveTransportFailure.ts', + 'src/storage/snapshot/archive/RemoteSnapshotLease.ts', + 'src/storage/snapshot/archive/RemoteSnapshotRows.ts', + 'src/storage/snapshot/archive/RemoteSnapshotPageReader.ts', + 'src/storage/snapshot/archive/openRemoteSnapshot.ts', + 'src/storage/snapshot/SnapshotCursor.ts', + 'src/storage/snapshot/SnapshotCancelledError.ts' + ], + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/snapshot/archive/RemoteSnapshot*.test.ts', + '/src/storage/snapshot/archive/SnapshotArchiveReader*.test.ts', + '/src/storage/snapshot/archive/SnapshotArchiveAdmission.test.ts', + '/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.test.ts', + '/src/storage/snapshot/SnapshotCursor.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + }, ...snapshotSyncMutationTargets(repositoryRoot), 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index b836dfca4..b7bac7aa4 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved." + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle.", + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved." + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle.", + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged." }, { "name": "create-bsv-app", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index feb875e1a..96640654f 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -561,6 +561,19 @@ "Ready receipts bind one archive/root while every distinct terminal state remains explicit.", "Unexpected identity, expiry or ownership fields are rejected rather than silently normalized." ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet immutable remote snapshot row decoding, fixed archive cursors, bounded leases and exact admission recovery", + "target": "Generated packed frame and page boundaries, immutable retry positions and per-page allocation accounting", + "invariants": [ + "Every generated row is returned exactly once in original source order across arbitrary frame and caller-page boundaries.", + "The archive position binds the exact frame and last consumed row without reading preceding tables.", + "Same-position retries reproduce identical rows, cursor and allocation charge.", + "A cursor for another archive is rejected and each opened reader cancels its original immutable request exactly once." + ] } ], "exclusions": [ diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index faddb44a9..07adee2aa 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,16 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add the unadvertised remote row-reader foundation: immutable server-issued + offers, exact-request retry, fixed leases, verified packed rows and durable + cursor integration with local sync. Reader advertisement stays disabled while + cross-replica physical cleanup and owner recovery remain incomplete. + +- Preserve the public hash-wasm Argon2id generic signature in emitted types. + Strict CommonJS and ESM require-consumer checks cover binary/string results + without ambient internal-bundle declarations. Runtime bytes and SDK peer ranges + remain unchanged. + - Add shared SQL snapshot staging with profile-bound internal capture ownership, immutable completed pages, exact replay receipts, explicit logical byte/page reservations and resumable bounded cleanup. A second auxiliary migration diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 31f24c063..5ced878f9 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", @@ -67,7 +67,7 @@ "format:check": "pnpm --workspace-root exec prettier --check \"packages/wallet/wallet-toolbox/{README.md,jest.config.cjs,package.json,tsconfig*.json}\"", "lint": "oxlint src test benchmarks examples operator --deny-warnings", "lint:ci": "pnpm lint", - "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes cjs --exports Wallet,WalletSigner,WalletStorageManager,StorageClient,StorageKnex,Services,Setup,WalletPermissionsManager,WalletSettingsManager,LocalChainTracker,FixedWindowBulkFileDownloadBudget,DurableFileBulkFileDownloadBudget,BulkFileDataCacheFs,NodeBulkFileDataValidator,sdk --entry-exports \"./out/src/sdk=WalletError|WERR_BAD_REQUEST|WERR_INTERNAL|WERR_INVALID_PARAMETER|WERR_UNAUTHORIZED\"", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes cjs --exports Wallet,WalletSigner,WalletStorageManager,StorageClient,StorageKnex,Services,Setup,WalletPermissionsManager,WalletSettingsManager,LocalChainTracker,FixedWindowBulkFileDownloadBudget,DurableFileBulkFileDownloadBudget,BulkFileDataCacheFs,NodeBulkFileDataValidator,sdk --entry-exports \"./out/src/sdk=WalletError|WERR_BAD_REQUEST|WERR_INTERNAL|WERR_INVALID_PARAMETER|WERR_UNAUTHORIZED\" && tsc --project test/consumer/tsconfig.json", "typecheck": "tsc --build --pretty false && tsc --project examples/tsconfig.json --pretty false && tsc --project operator/tsconfig.json --noEmit --pretty false", "build": "tsc --build", "prepublishOnly": "pnpm build", diff --git a/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts b/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts index 8677850ca..fa07a44f9 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts @@ -38,6 +38,7 @@ import { import { WalletServices } from './WalletServices.interfaces' import { Chain, Paged, ProvenTxReqStatus, TransactionStatus } from './types' import { WalletError } from './WalletError' +import type { SnapshotSyncStorage } from '../storage/snapshot/SnapshotSync' import { AbortActionBatchResult, ActionBatchManifest, @@ -146,7 +147,7 @@ export interface WalletStorageProvider extends WalletStorageSync { setServices: (v: WalletServices) => void } -export interface WalletStorageSync extends WalletStorageWriter { +export interface WalletStorageSync extends WalletStorageWriter, WalletStorageSyncReader { /** Compact writer checkpoint. Undefined means a legacy remote provider. */ getSyncCheckpoint?: ( auth: AuthId, @@ -177,6 +178,8 @@ export interface WalletStorageSync extends WalletStorageWriter { export interface WalletStorageSyncReader { makeAvailable: () => Promise getSyncChunk: (args: RequestSyncChunkArgs) => Promise + /** Optional local adapter; immutable remote sources negotiate their capability when opened. */ + getSnapshotSync?: () => SnapshotSyncStorage | undefined } export interface WalletStorageWriter extends WalletStorageReader { diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 2cc3d4993..8ecb0bce3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,4 +1,5 @@ import { SnapshotResourceLimitError } from './snapshot/SnapshotResourceLimitError' +import { SnapshotCancelledError } from './snapshot/SnapshotCancelledError' import type { SnapshotSyncStorage } from './snapshot/SnapshotSync' import { KnexSnapshotSyncDestination } from './snapshot/KnexSnapshotSyncDestination' import type { WalletReadSnapshot, WalletReadSnapshotOptions } from './snapshot/WalletReadSnapshot' @@ -382,7 +383,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide deadline: { expiresAt: number; startedAt: number; lifetimeMs: number }, openSource: (reader: StorageKnex, identityKey: string, options: WalletReadSnapshotOptions) => Promise ): Promise { - if (options.signal?.aborted === true) throw new WERR_INVALID_OPERATION('Snapshot sync source was cancelled') + if (options.signal?.aborted === true) throw new SnapshotCancelledError('Snapshot sync source was cancelled') const config = await this.concurrentSnapshotReaderConfig() if (config === undefined) return undefined if (this.retainedReadSnapshotsStopped) diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index 970b05687..21b229ccd 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -1,4 +1,6 @@ import { SnapshotResourceLimitError } from './snapshot/SnapshotResourceLimitError' +import { SnapshotCancelledError } from './snapshot/SnapshotCancelledError' +import { copySnapshotCursor } from './snapshot/SnapshotCursor' import { runSnapshotSyncSession } from './snapshot/runSnapshotSyncSession' import type { SnapshotSyncStorage } from './snapshot/SnapshotSync' import type { WalletReadSnapshot } from './snapshot/WalletReadSnapshot' @@ -857,16 +859,23 @@ export class WalletStorageManager implements sdk.WalletStorage { selection?: { generation: number; active: sdk.WalletStorageProvider } ): Promise { const selected = selection ?? { generation: this.generation, active: this.getActive() } - const source = reader instanceof StorageProvider ? reader.getSnapshotSync() : undefined - const destination = writer instanceof StorageProvider ? writer.getSnapshotSync() : undefined + const source = reader.getSnapshotSync?.() + const destination = writer.getSnapshotSync?.() if (source === undefined || destination === undefined || reader === writer) return undefined if (!(await destination.supportsDestination())) return undefined - if (options.signal?.aborted === true) - return { status: 'cancelled', mode: 'paged', pages: 0, inserts: 0, updates: 0 } + const cancelled = (): boolean => options.signal?.aborted === true + if (cancelled()) return { status: 'cancelled', mode: 'paged', pages: 0, inserts: 0, updates: 0 } let view: WalletReadSnapshot | undefined - let partial = { pages: 0, inserts: 0, updates: 0 } + let partial: Pick = { + pages: 0, + inserts: 0, + updates: 0 + } try { - view = await source.openSource(this._authId.identityKey, { lifetimeMs: options.snapshotLifetimeMs }) + view = await source.openSource(this._authId.identityKey, { + lifetimeMs: options.snapshotLifetimeMs, + signal: options.signal + }) if (view === undefined) return undefined let failed = false try { @@ -877,7 +886,15 @@ export class WalletStorageManager implements sdk.WalletStorage { { ...options, onProgress: progress => { - partial = { pages: progress.pages, inserts: progress.inserts, updates: progress.updates } + partial = { + pages: progress.pages, + inserts: progress.inserts, + updates: progress.updates, + snapshotCheckpoint: + progress.snapshotCheckpoint === undefined + ? undefined + : { ...progress.snapshotCheckpoint, cursor: copySnapshotCursor(progress.snapshotCheckpoint.cursor) } + } options.onProgress?.(progress) } }, @@ -891,14 +908,17 @@ export class WalletStorageManager implements sdk.WalletStorage { await view.close().catch(error => { // A simultaneous expiry must not replace a malformed-row/session error. // A physical cleanup failure, however, cannot be hidden by fallback. - if (!failed || !(error instanceof SnapshotResourceLimitError)) throw error + if (source.fallbackOnResourceError === false || !failed || !(error instanceof SnapshotResourceLimitError)) + throw error }) } } catch (error) { + if (error instanceof SnapshotCancelledError && cancelled()) + return { status: 'cancelled', mode: 'paged', ...partial } // Existing ordinary backups must continue to accept large records and long // copies. Only explicit resource limits select the established serialized // fallback; corrupt rows, changed sessions and I/O failures still reject. - if (!(error instanceof SnapshotResourceLimitError)) throw error + if (source.fallbackOnResourceError === false || !(error instanceof SnapshotResourceLimitError)) throw error } const result = await this.runLegacySnapshotFallback( reader, diff --git a/packages/wallet/wallet-toolbox/src/storage/index.all.ts b/packages/wallet/wallet-toolbox/src/storage/index.all.ts index 41dd9e82b..6af578d85 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.all.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.all.ts @@ -27,6 +27,7 @@ export type { PackedSnapshotRow, WalletReadSnapshot, WalletReadSnapshotOptions, + WalletSnapshotArchivePosition, WalletSnapshotCursor, WalletSnapshotPage, WalletSnapshotPageLimits, diff --git a/packages/wallet/wallet-toolbox/src/storage/index.client.ts b/packages/wallet/wallet-toolbox/src/storage/index.client.ts index da778e40b..6b25444c0 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.client.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.client.ts @@ -18,6 +18,7 @@ export type { PackedSnapshotRow, WalletReadSnapshot, WalletReadSnapshotOptions, + WalletSnapshotArchivePosition, WalletSnapshotCursor, WalletSnapshotPage, WalletSnapshotPageLimits, diff --git a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts index a481d18db..60b1a3133 100644 --- a/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts +++ b/packages/wallet/wallet-toolbox/src/storage/index.mobile.ts @@ -16,6 +16,7 @@ export type { PackedSnapshotRow, WalletReadSnapshot, WalletReadSnapshotOptions, + WalletSnapshotArchivePosition, WalletSnapshotCursor, WalletSnapshotPage, WalletSnapshotPageLimits, diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index 38f6164db..934ee4f5c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -73,7 +73,7 @@ import { StorageCapabilities } from '../../sdk/ActionBatch.interfaces' import { TableSettings } from '../schema/tables/TableSettings' -import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { WERR_INVALID_OPERATION, WERR_NOT_IMPLEMENTED } from '../../sdk/WERR_errors' import { WalletServices } from '../../sdk/WalletServices.interfaces' import { TableUser } from '../schema/tables/TableUser' import { TableSyncState } from '../schema/tables/TableSyncState' @@ -92,6 +92,10 @@ import { } from '../../utility/actionBatchPack' import { pruneBeefForTxids } from '../../utility/beefForTxids' import { SnapshotArchiveTransport } from '../snapshot/archive/SnapshotArchiveTransport' +import { openRemoteSnapshot } from '../snapshot/archive/openRemoteSnapshot' +import { SnapshotCancelledError } from '../snapshot/SnapshotCancelledError' +import { snapshotArchiveFetch } from '../snapshot/archive/SnapshotArchiveTransportFailure' +import type { SnapshotSyncStorage } from '../snapshot/SnapshotSync' import { snapshotArchiveResponseBytes, snapshotArchiveRequestBytes, @@ -114,6 +118,7 @@ type RemoteStorageSettings = TableSettings & { syncCheckpointVersion?: 1 syncTransfer?: SyncTransferCapabilities snapshotArchive?: SnapshotArchiveCapabilities + snapshotArchiveReaderVersion?: 1 } export interface StorageClientOptions { @@ -264,6 +269,12 @@ function validateRemoteStorageSettings(value: unknown): RemoteStorageSettings { throw new Error('Wallet storage returned invalid settings.') } if (properties.snapshotArchive != null) validateSnapshotArchiveCapabilities(properties.snapshotArchive.value) + if ( + properties.snapshotArchiveReaderVersion != null && + (properties.snapshotArchiveReaderVersion.value !== 1 || properties.snapshotArchive == null) + ) { + throw new Error('Wallet storage returned invalid settings.') + } return value as RemoteStorageSettings } @@ -287,7 +298,7 @@ export abstract class StorageClientBase implements WalletStorageProvider { private readonly snapshotArchivesEnabled: boolean private readonly snapshotWallet: WalletInterface private snapshotAuthClient?: AuthFetch - private snapshotSource?: { identityKey: string; chain: 'main' | 'test' } + private snapshotSource?: { identityKey: string; chain: 'main' | 'test'; supportsReader: boolean } /** Optional progress/cancellation hook for a bounded transfer; never receives wallet contents. */ onSyncTransferProgress?: (progress: { direction: 'read' | 'write'; bytes: number; totalBytes: number }) => void @@ -324,6 +335,23 @@ export abstract class StorageClientBase implements WalletStorageProvider { return response } + /** Source-only adapter. Opening performs authenticated capability negotiation, including on the first sync. */ + getSnapshotSync(): SnapshotSyncStorage | undefined { + if (!this.snapshotArchivesEnabled) return undefined + const unavailable = () => Promise.reject(new WERR_NOT_IMPLEMENTED('Remote snapshot destination is unavailable')) + return { + fallbackOnResourceError: false, + supportsDestination: () => Promise.resolve(false), + openSource: async (identityKey, options) => { + const transport = await this.getSnapshotArchiveTransport(identityKey) + return transport === undefined ? undefined : await openRemoteSnapshot(transport, options) + }, + begin: unavailable, + checkpoint: unavailable, + prepare: unavailable + } + } + /** Available only after an authenticated compatible advertisement. */ async getSnapshotArchiveTransport(identityKey: string): Promise { await this.makeAvailable() @@ -332,7 +360,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { (method, params, signal) => this.snapshotRpcCall(method, params, signal), identityKey, this.snapshotSource.identityKey, - this.snapshotSource.chain + this.snapshotSource.chain, + this.snapshotSource.supportsReader ) } @@ -345,17 +374,35 @@ export abstract class StorageClientBase implements WalletStorageProvider { const body = JSON.stringify({ jsonrpc: '2.0', method, params, id }) if (new TextEncoder().encode(body).length > snapshotArchiveRequestBytes) throw new TypeError('Snapshot archive request exceeds its transport limit') - this.snapshotAuthClient ??= new AuthFetch(this.snapshotWallet, undefined, undefined, undefined, { - maxResponseBytes: snapshotArchiveResponseBytes - }) - const response = this.validateAuthenticatedResponse( - await this.snapshotAuthClient.fetch(this.endpointUrl, { + this.snapshotAuthClient ??= new AuthFetch( + this.snapshotWallet, + undefined, + undefined, + undefined, + { maxResponseBytes: snapshotArchiveResponseBytes }, + snapshotArchiveFetch(fetch) + ) + let received: Response + try { + received = await this.snapshotAuthClient.fetch(this.endpointUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', [BINARY_ENCODING_HEADER]: BINARY_ENCODING }, body, signal }) - ) + } catch (error) { + // AuthFetch uses its typed cancellation code before a native fetch may + // exist. Inspect only its own data code here, before RPC/response parsing; + // this also works with older SDKs that lack the exported error class. + if ( + signal?.aborted === true && + error instanceof Error && + Object.getOwnPropertyDescriptor(error, 'code')?.value === 'ERR_PAYMENT_CANCELLED' + ) + throw new SnapshotCancelledError('Snapshot archive authenticated request was cancelled') + throw error + } + const response = this.validateAuthenticatedResponse(received) if (!response.ok) throw this.rpcResponseError(response) if (response.headers.get(BINARY_ENCODING_HEADER) !== BINARY_ENCODING) throw new TypeError('Snapshot archive requires compact binary responses') @@ -471,7 +518,11 @@ export abstract class StorageClientBase implements WalletStorageProvider { throw new Error('Wallet storage settings identity does not match the configured storage identity.') } if (settings.snapshotArchive !== undefined && (settings.chain === 'main' || settings.chain === 'test')) { - this.snapshotSource = { identityKey: storageIdentityKey, chain: settings.chain } + this.snapshotSource = { + identityKey: storageIdentityKey, + chain: settings.chain, + supportsReader: settings.snapshotArchiveReaderVersion === 1 + } } this.settings = settings return this.settings diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts index 5b19e51f5..025ecda3a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexSnapshotSyncDestination.ts @@ -18,6 +18,7 @@ import { } from './SnapshotSync' import type { WalletSnapshotPage } from './WalletReadSnapshot' import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' +import { copySnapshotCursor, sameSnapshotArchivePosition } from './SnapshotCursor' interface SessionRow { version: 1 @@ -71,7 +72,8 @@ function notMatchingCheckpoint(actual: SnapshotSyncCheckpoint, expected: Snapsho actual.cursor?.version !== expected.cursor?.version || actual.cursor?.snapshotId !== expected.cursor?.snapshotId || actual.cursor?.table !== expected.cursor?.table || - JSON.stringify(actual.cursor?.after) !== JSON.stringify(expected.cursor?.after) + JSON.stringify(actual.cursor?.after) !== JSON.stringify(expected.cursor?.after) || + !sameSnapshotArchivePosition(actual.cursor?.archivePosition, expected.cursor?.archivePosition) ) } @@ -222,7 +224,7 @@ export class KnexSnapshotSyncDestination { ): Promise<() => Promise> { const expected: SnapshotSyncCheckpoint = { ...input, - cursor: input.cursor === undefined ? undefined : { ...input.cursor, after: [...input.cursor.after] } + cursor: copySnapshotCursor(input.cursor) } if ( expected.version !== 1 || diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts index d3c6eac72..065e71183 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts @@ -1,4 +1,5 @@ import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' +import { SnapshotCancelledError } from './SnapshotCancelledError' import type { TrxToken } from '../../sdk/WalletStorage.interfaces' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' @@ -86,7 +87,7 @@ export function retainReadSnapshot( opened.reject(reason) stopped.resolve() } - const abort = (): void => stop(new WERR_INVALID_OPERATION('Retained read snapshot was cancelled')) + const abort = (): void => stop(new SnapshotCancelledError('Retained read snapshot was cancelled')) const checkDeadline = (): void => { if (Date.now() >= expiresAt || performance.now() - startedAt >= lifetimeMs) { stop(new SnapshotResourceLimitError('Retained read snapshot expired')) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCancelledError.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCancelledError.ts new file mode 100644 index 000000000..46b0a10b9 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCancelledError.ts @@ -0,0 +1,4 @@ +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' + +/** A local explicit signal cancellation, distinct from validation or cleanup failure. */ +export class SnapshotCancelledError extends WERR_INVALID_OPERATION {} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCursor.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCursor.test.ts new file mode 100644 index 000000000..b49febc21 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCursor.test.ts @@ -0,0 +1,84 @@ +import { copySnapshotArchivePosition, copySnapshotCursor, sameSnapshotArchivePosition } from './SnapshotCursor' +import type { WalletSnapshotArchivePosition, WalletSnapshotCursor } from './WalletReadSnapshot' + +const position: WalletSnapshotArchivePosition = { version: 1, archiveId: 'a'.repeat(64), sequence: 0, rowOffset: 1 } +const legacy: WalletSnapshotCursor = { version: 1, snapshotId: 'b'.repeat(64), table: 'txLabels', after: [1] } + +test('legacy cursor JSON stays byte-identical while archive metadata and keys are detached', () => { + expect(copySnapshotCursor(undefined)).toBeUndefined() + const copiedLegacy = copySnapshotCursor(legacy) + expect(JSON.stringify(copiedLegacy)).toBe(JSON.stringify(legacy)) + expect(copiedLegacy).not.toHaveProperty('archivePosition') + expect(copiedLegacy.after).not.toBe(legacy.after) + const current = { ...legacy, archivePosition: position } + const copy = copySnapshotCursor(current) + expect(copy).toEqual(current) + expect(copy.archivePosition).not.toBe(position) + expect(copy.after).not.toBe(current.after) + expect(Object.keys(copy.archivePosition!)).toEqual(['version', 'archiveId', 'sequence', 'rowOffset']) + expect(copySnapshotArchivePosition({ ...position, sequence: 4095, rowOffset: 1000 })).toEqual({ + ...position, + sequence: 4095, + rowOffset: 1000 + }) +}) + +test.each([ + { version: 2 }, + { archiveId: '' }, + { archiveId: 'A'.repeat(64) }, + { archiveId: 'g'.repeat(64) }, + { archiveId: 'a'.repeat(63) }, + { archiveId: 'a'.repeat(65) }, + { archiveId: 1 }, + { sequence: -1 }, + { sequence: 4096 }, + { sequence: 1.5 }, + { sequence: NaN }, + { sequence: '1' }, + { rowOffset: 0 }, + { rowOffset: 1001 }, + { rowOffset: 1.5 }, + { rowOffset: NaN }, + { rowOffset: '1' }, + { extra: true }, + { [Symbol('extra')]: true } +])('position accepts only bounded exact metadata %p', change => { + expect(() => copySnapshotArchivePosition({ ...position, ...change })).toThrow('bounded version-one archive position') +}) + +test.each([undefined, null, [], 'position', 1, true])('rejects non-record positions %p', value => { + expect(() => copySnapshotArchivePosition(value)).toThrow('bounded version-one archive position') +}) + +test('positions require own enumerable data without invoking getters', () => { + for (const name of Object.keys(position)) { + const value = { ...position } as Record + delete value[name] + expect(() => copySnapshotArchivePosition(value)).toThrow() + Object.defineProperty(value, name, { value: Reflect.get(position, name), configurable: true }) + expect(() => copySnapshotArchivePosition(value)).toThrow() + Object.defineProperty(value, name, { + enumerable: true, + get: () => { + throw new Error('Getter must not run') + } + }) + expect(() => copySnapshotArchivePosition(value)).toThrow('bounded version-one archive position') + } + expect(() => copySnapshotArchivePosition(Object.create(position))).toThrow() +}) + +test('acknowledgements compare every archive-position binding and distinguish missing metadata', () => { + expect(sameSnapshotArchivePosition(undefined, undefined)).toBe(true) + expect(sameSnapshotArchivePosition(position, undefined)).toBe(false) + expect(sameSnapshotArchivePosition(undefined, position)).toBe(false) + expect(sameSnapshotArchivePosition(position, { ...position })).toBe(true) + expect( + sameSnapshotArchivePosition(position, { rowOffset: 1, sequence: 0, archiveId: 'a'.repeat(64), version: 1 }) + ).toBe(true) + for (const change of [{ version: 2 }, { archiveId: 'b'.repeat(64) }, { sequence: 1 }, { rowOffset: 2 }]) + expect(sameSnapshotArchivePosition(position, { ...position, ...change } as WalletSnapshotArchivePosition)).toBe( + false + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCursor.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCursor.ts new file mode 100644 index 000000000..cf4f6c6b5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCursor.ts @@ -0,0 +1,68 @@ +import { WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' +import type { WalletSnapshotArchivePosition, WalletSnapshotCursor } from './WalletReadSnapshot' + +function invalid(): never { + throw new WERR_INVALID_PARAMETER('cursor.archivePosition', 'a bounded version-one archive position') +} + +/** Validates additive metadata without changing the shape of legacy cursors. */ +export function copySnapshotArchivePosition(input: unknown): WalletSnapshotArchivePosition { + const names = ['version', 'archiveId', 'sequence', 'rowOffset'] + if ( + input === null || + typeof input !== 'object' || + Array.isArray(input) || + Reflect.ownKeys(input).length !== names.length + ) + invalid() + const value: Record = {} + for (const name of names) { + const property = Object.getOwnPropertyDescriptor(input, name) + if (property === undefined || !('value' in property) || !property.enumerable) invalid() + value[name] = property.value + } + if ( + value.version !== 1 || + typeof value.archiveId !== 'string' || + !/^[0-9a-f]{64}$/.test(value.archiveId) || + !Number.isSafeInteger(value.sequence) || + (value.sequence as number) < 0 || + (value.sequence as number) >= 4096 || + !Number.isSafeInteger(value.rowOffset) || + (value.rowOffset as number) < 1 || + (value.rowOffset as number) > 1000 + ) + invalid() + return { + version: 1, + archiveId: value.archiveId, + sequence: value.sequence as number, + rowOffset: value.rowOffset as number + } +} + +export function copySnapshotCursor(cursor: WalletSnapshotCursor): WalletSnapshotCursor +export function copySnapshotCursor(cursor: WalletSnapshotCursor | undefined): WalletSnapshotCursor | undefined +export function copySnapshotCursor(cursor: WalletSnapshotCursor | undefined): WalletSnapshotCursor | undefined { + if (cursor === undefined) return undefined + return { + ...cursor, + after: [...cursor.after], + ...(cursor.archivePosition === undefined + ? {} + : { archivePosition: copySnapshotArchivePosition(cursor.archivePosition) }) + } +} + +export function sameSnapshotArchivePosition( + left: WalletSnapshotArchivePosition | undefined, + right: WalletSnapshotArchivePosition | undefined +): boolean { + if (left === undefined || right === undefined) return left === right + return ( + left.version === right.version && + left.archiveId === right.archiveId && + left.sequence === right.sequence && + left.rowOffset === right.rowOffset + ) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts index 2101f631b..bec51a428 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts @@ -12,6 +12,7 @@ import type { WalletReadSnapshot } from './WalletReadSnapshot' import { runSnapshotSyncSession } from './runSnapshotSyncSession' import { KnexSnapshotSyncDestination } from './KnexSnapshotSyncDestination' import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' +import { SnapshotCancelledError } from './SnapshotCancelledError' import { runInSeries } from '../../utility/runInSeries' import { WERR_UNAUTHORIZED } from '../../sdk/WERR_errors' @@ -542,21 +543,129 @@ describe.each(primaryCopyModes)('primary metadata through %s', mode => { }) }) -test('snapshot pull preserves the active destination and remaps into an occupied profile', async () => { - const { source, destination } = await fixture(4) - const { user: foreign } = await destination.findOrInsertUser(foreignIdentity) - await destination.findOrInsertTxLabel(foreign.userId, 'foreign-existing') - const user = (await destination.findUserByIdentityKey(identity))! - await destination.updateUser(user.userId, { activeStorage: 'destination' }) - const manager = new WalletStorageManager(identity, destination) - await manager.makeAvailable() - const result = await manager.syncFromReaderResumable(identity, source, { maxItems: 2 }) - expect(result.snapshotCheckpoint?.done).toBe(true) - expect((await destination.findUserByIdentityKey(identity))!.activeStorage).toBe('destination') - expect(await destination.findTxLabels({ partial: { userId: user.userId } })).toHaveLength(4) - expect(await destination.findTxLabels({ partial: { userId: foreign.userId } })).toHaveLength(1) +test.each(['provider', 'adapter'] as const)( + 'snapshot pull through a %s preserves the active destination and remaps into an occupied profile', + async kind => { + const { source, destination } = await fixture(4) + const { user: foreign } = await destination.findOrInsertUser(foreignIdentity) + await destination.findOrInsertTxLabel(foreign.userId, 'foreign-existing') + const user = (await destination.findUserByIdentityKey(identity))! + await destination.updateUser(user.userId, { activeStorage: 'destination' }) + const manager = new WalletStorageManager(identity, destination) + await manager.makeAvailable() + const reader = + kind === 'provider' + ? source + : { + makeAvailable: source.makeAvailable.bind(source), + getSyncChunk: source.getSyncChunk.bind(source), + getSnapshotSync: source.getSnapshotSync.bind(source) + } + const legacy = jest.spyOn(destination, 'processSyncChunk') + const result = await manager.syncFromReaderResumable(identity, reader, { maxItems: 2 }) + expect(legacy).not.toHaveBeenCalled() + expect(result.snapshotCheckpoint?.done).toBe(true) + expect((await destination.findUserByIdentityKey(identity))!.activeStorage).toBe('destination') + expect(await destination.findTxLabels({ partial: { userId: user.userId } })).toHaveLength(4) + expect(await destination.findTxLabels({ partial: { userId: foreign.userId } })).toHaveLength(1) + } +) + +test('typed cancellation during source reading awaits cleanup and returns the last durable checkpoint', async () => { + const { source, destination, manager } = await fixture(3) + const controller = new AbortController() + const sourceCapability = source.getSnapshotSync()! + const closing = deferred() + const release = deferred() + jest.spyOn(source, 'getSnapshotSync').mockReturnValue({ + ...sourceCapability, + fallbackOnResourceError: false, + openSource: async (key, options) => { + expect(options?.signal).toBe(controller.signal) + const view = (await sourceCapability.openSource(key, options))! + return { + ...view, + readPage: async (...args) => { + if (args[0] === 'txLabels') { + controller.abort() + throw new SnapshotCancelledError('synthetic cancelled read') + } + return await view.readPage(...args) + }, + close: async () => { + closing.resolve() + await release.promise + await view.close() + } + } + } + }) + const legacy = jest.spyOn(destination, 'processSyncChunk') + const pending = observe( + manager.syncToWriterResumable(await manager.getAuth(), destination, { signal: controller.signal }) + ) + let finished = false + void pending.then( + () => { + finished = true + }, + () => { + finished = true + } + ) + try { + await waitForBoundary(closing.promise, pending) + expect(finished).toBe(false) + release.resolve() + const result = await pending + expect(result).toMatchObject({ status: 'cancelled', pages: 3, mode: 'paged' }) + expect(result.snapshotCheckpoint).toEqual(await destination.getSnapshotSync()!.checkpoint(identity, 'source')) + expect(legacy).not.toHaveBeenCalled() + expect(Reflect.get(source, 'snapshotSyncSource')).toBeUndefined() + } finally { + release.resolve() + await pending.catch(() => undefined) + } }) +test.each(['resource', 'validation', 'cleanup'] as const)( + 'source policy propagates %s failure without selecting fallback or mistaking it for cancellation', + async failureKind => { + const { source, destination, manager } = await fixture(1) + const controller = new AbortController() + const capability = source.getSnapshotSync()! + const failure = + failureKind === 'validation' + ? new Error('synthetic invalid row') + : new SnapshotResourceLimitError('synthetic resource failure') + jest.spyOn(source, 'getSnapshotSync').mockReturnValue({ + ...capability, + fallbackOnResourceError: false, + openSource: async (...args) => { + const view = (await capability.openSource(...args))! + return { + ...view, + readPage: async () => { + controller.abort() + throw failureKind === 'cleanup' ? new Error('synthetic row before cleanup failure') : failure + }, + close: async () => { + await view.close() + if (failureKind === 'cleanup') throw failure + } + } + } + }) + const legacy = jest.spyOn(destination, 'processSyncChunk') + await expect( + manager.syncToWriterResumable(await manager.getAuth(), destination, { signal: controller.signal }) + ).rejects.toBe(failure) + expect(legacy).not.toHaveBeenCalled() + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(0) + expect(Reflect.get(source, 'snapshotSyncSource')).toBeUndefined() + } +) + async function seedClosure(source: StorageKnex, userId: number, otherId: number): Promise { const date = when.toISOString() const timestamp = { created_at: date, updated_at: date } @@ -1067,6 +1176,37 @@ test('rejects changed checkpoints and malformed pages without advancing durable await view.close() }) +test('archive positions persist with rows, detach before proof I/O and reject an omitted durable binding', async () => { + const { source, destination } = await fixture(3) + const view = (await source.getSnapshotSync()!.openSource(identity))! + const writer = destination.getSnapshotSync()! + try { + let checkpoint = await advance(view, writer, 3) + const first = await view.readPage('txLabels', undefined, { maxRows: 1 }) + const second = await view.readPage('txLabels', first.cursor, { maxRows: 1 }) + const last = await view.readPage('txLabels', second.cursor, { maxRows: 1 }) + first.cursor!.archivePosition = { version: 1, archiveId: 'c'.repeat(64), sequence: 3, rowOffset: 1 } + checkpoint = (await (await writer.prepare(checkpoint, first))()).checkpoint + expect((await writer.checkpoint(identity, 'source'))!.cursor!.archivePosition).toEqual( + first.cursor!.archivePosition + ) + second.cursor!.archivePosition = { ...first.cursor!.archivePosition, rowOffset: 2 } + const preparing = writer.prepare(checkpoint, second) + checkpoint.cursor!.archivePosition!.rowOffset = 999 + second.cursor!.archivePosition.rowOffset = 999 + checkpoint = (await (await preparing)()).checkpoint + expect(checkpoint.cursor!.archivePosition!.rowOffset).toBe(2) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) + const missing = { ...checkpoint, cursor: { ...checkpoint.cursor!, archivePosition: undefined } } + const apply = await writer.prepare(missing, last) + await expect(apply()).rejects.toThrow('Snapshot session changed') + expect(await writer.checkpoint(identity, 'source')).toEqual(checkpoint) + expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) + } finally { + await view.close() + } +}) + test('source admission, expiry and destruction retain bounded reader ownership', async () => { const { source, destination } = await fixture(0) const capability = source.getSnapshotSync()! diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.ts index 4e68ad389..b19179194 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.ts @@ -52,8 +52,10 @@ export interface SnapshotSyncCommit { updates: number } -/** Additive local provider capability. These methods are deliberately absent from the RPC allowlist. */ +/** Additive provider capability. These adapter methods are absent from the RPC allowlist. */ export interface SnapshotSyncStorage { + /** False prevents switching away from an accepted immutable remote view on a resource error. */ + fallbackOnResourceError?: boolean /** True only after the version-one auxiliary schema and primary fencing migration committed. */ supportsDestination: () => Promise /** Undefined means this configuration cannot retain a view while foreground writes proceed. */ diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.test.ts index 586ac07a4..cd3a3f9c4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.test.ts @@ -168,6 +168,24 @@ function boundedPage(rows: unknown[], payloadBytes = 16777216): WalletSnapshotPa } as WalletSnapshotPage<'txLabels'> } +test('archive positions are bounded before persistence while legacy cursor bytes stay unchanged', () => { + const page = { ...boundedPage([{ txLabelId: 1 }]), done: false } + expect(detachSnapshotSyncPage(boundedCheckpoint, page).nextCursor).toBe(JSON.stringify(page.cursor)) + const position = { version: 1 as const, archiveId: 'c'.repeat(64), sequence: 3, rowOffset: 1 } + page.cursor!.archivePosition = position + const persisted = detachSnapshotSyncPage(boundedCheckpoint, page).nextCursor! + position.rowOffset = 2 + expect(JSON.parse(persisted).archivePosition.rowOffset).toBe(1) + for (const invalid of [ + { ...position, rowOffset: 0 }, + { ...position, sequence: 4096 }, + { ...position, extra: 1 } + ]) { + page.cursor!.archivePosition = invalid + expect(() => detachSnapshotSyncPage(boundedCheckpoint, page)).toThrow('cursor.archivePosition') + } +}) + test.each([ [NaN, 'finite numbers'], [Infinity, 'finite numbers'], diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts index 4d716dcaf..d3d3da400 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.ts @@ -7,6 +7,7 @@ import { snapshotSyncPage } from '../sync/snapshotSyncPage' import { snapshotSyncTables, type SnapshotSyncCheckpoint, type SnapshotSyncTable } from './SnapshotSync' import type { WalletSnapshotPage } from './WalletReadSnapshot' import { runInSeries } from '../../utility/runInSeries' +import { copySnapshotArchivePosition } from './SnapshotCursor' const entities: Record = { provenTxs: 'provenTx', @@ -104,6 +105,7 @@ export function detachSnapshotSyncPage( } validateAllocation(page.rows, page.payloadBytes) const cursor = page.cursor + if (cursor?.archivePosition !== undefined) copySnapshotArchivePosition(cursor.archivePosition) if (page.rows.length > 0) { const last = page.rows.at(-1) as unknown as Record if ( diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts index c3a991184..829ba4b03 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts @@ -1,5 +1,5 @@ import { runSnapshotSyncSession } from './runSnapshotSyncSession' -import type { WalletReadSnapshot } from './WalletReadSnapshot' +import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' import type { SnapshotSyncCheckpoint, SnapshotSyncCommit, SnapshotSyncStorage } from './SnapshotSync' import type { SyncSessionOptions, SyncSessionProgress } from '../sync/syncSession' import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' @@ -56,6 +56,7 @@ test.each([ test('progress uses detached checkpoints and reports the committed counts and timings', async () => { const f = session() + f.checkpoint.cursor!.archivePosition = { version: 1, archiveId: 'c'.repeat(64), sequence: 12, rowOffset: 7 } const states: SyncSessionProgress['state'][] = [] const result = await runSnapshotSyncSession(f.input, { maxItems: 17, @@ -63,6 +64,8 @@ test('progress uses detached checkpoints and reports the committed counts and ti onProgress: progress => { states.push(progress.state) if (progress.snapshotCheckpoint?.cursor) progress.snapshotCheckpoint.cursor.after[0] = 999 + if (progress.snapshotCheckpoint?.cursor?.archivePosition) + progress.snapshotCheckpoint.cursor.archivePosition.rowOffset = 999 if (progress.snapshotCheckpoint) progress.snapshotCheckpoint.identityKey = 'changed-by-listener' if (progress.state === 'committed') { expect(progress).toMatchObject({ pages: 1, inserts: 2, updates: 3 }) @@ -80,6 +83,7 @@ test('progress uses detached checkpoints and reports the committed counts and ti expect(limits.maxRows).toBeGreaterThanOrEqual(1) expect(limits.maxRows).toBeLessThanOrEqual(17) expect(f.checkpoint.cursor!.after).toEqual([7]) + expect(f.checkpoint.cursor!.archivePosition!.rowOffset).toBe(7) expect(result).toMatchObject({ status: 'completed', mode: 'paged', @@ -186,14 +190,24 @@ test.each([ test('a nonterminal acknowledgement advances a detached cursor before finishing its table', async () => { const f = session() - const cursor = { version: 1 as const, snapshotId: 'b'.repeat(64), table: 'provenTxReqs' as const, after: [8] } + const cursor: WalletSnapshotCursor = { + version: 1, + snapshotId: 'b'.repeat(64), + table: 'provenTxReqs', + after: [8], + archivePosition: { version: 1, archiveId: 'c'.repeat(64), sequence: 12, rowOffset: 8 } + } ;(f.view.readPage as jest.Mock).mockResolvedValueOnce({ rows: [{ provenTxReqId: 8 }], payloadBytes: 128, done: false, cursor }) - const acknowledged: SnapshotSyncCheckpoint = { ...f.checkpoint, sequence: 12, cursor: { ...cursor, after: [8] } } + const acknowledged: SnapshotSyncCheckpoint = { + ...f.checkpoint, + sequence: 12, + cursor: { ...cursor, after: [8], archivePosition: { ...cursor.archivePosition! } } + } f.apply.mockResolvedValueOnce({ inserts: 1, updates: 0, checkpoint: acknowledged }) f.apply.mockResolvedValueOnce({ inserts: 0, @@ -204,6 +218,7 @@ test('a nonterminal acknowledgement advances a detached cursor before finishing onProgress: progress => { if (progress.state === 'committed' && progress.pages === 1) { acknowledged.cursor!.after[0] = 999 + acknowledged.cursor!.archivePosition!.rowOffset = 999 acknowledged.sequence = 999 } } @@ -212,6 +227,42 @@ test('a nonterminal acknowledgement advances a detached cursor before finishing expect(result).toMatchObject({ status: 'completed', pages: 2, inserts: 1, updates: 0 }) }) +test.each(['omitted', 'archive', 'sequence', 'offset'] as const)( + 'an acknowledgement with %s archive position cannot become the next read position', + async change => { + const f = session() + const position = { version: 1 as const, archiveId: 'c'.repeat(64), sequence: 12, rowOffset: 8 } + const cursor: WalletSnapshotCursor = { + version: 1, + snapshotId: 'b'.repeat(64), + table: 'provenTxReqs', + after: [8], + archivePosition: position + } + ;(f.view.readPage as jest.Mock).mockResolvedValue({ + rows: [{ provenTxReqId: 8 }], + payloadBytes: 128, + done: false, + cursor + }) + const changed = { ...position } + if (change === 'archive') changed.archiveId = 'd'.repeat(64) + if (change === 'sequence') changed.sequence++ + if (change === 'offset') changed.rowOffset++ + f.apply.mockResolvedValue({ + inserts: 1, + updates: 0, + checkpoint: { + ...f.checkpoint, + sequence: 12, + cursor: { ...cursor, archivePosition: change === 'omitted' ? undefined : changed } + } + }) + await expect(runSnapshotSyncSession(f.input, {})).rejects.toThrow('acknowledgement does not match') + expect(f.view.readPage).toHaveBeenCalledTimes(1) + } +) + test.each(['closed', 'expired', 'cleanup-failed'] as const)( 'source %s while queued prevents a destination write', async outcome => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/WalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/WalletReadSnapshot.ts index 47b9d90e8..fa92c60bc 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/WalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/WalletReadSnapshot.ts @@ -1,7 +1,7 @@ import type * as tables from '../schema/tables' import type { RetainedReadSnapshotOptions } from './RetainedReadSnapshot' -/** Version-one local row contract. Binary columns never expand into number arrays. */ +/** Version-one retained row contract. Binary columns never expand into number arrays. */ export type PackedSnapshotRow = { [K in keyof T]: PackedSnapshotValue } type PackedSnapshotValue = T extends number[] ? Uint8Array : T @@ -23,12 +23,21 @@ export interface WalletSnapshotTables { export type WalletSnapshotTable = keyof WalletSnapshotTables -/** A position within this live local view, not a durable checkpoint or authorization credential. */ +/** Verified position inside an immutable remote archive; never an authorization credential. */ +export interface WalletSnapshotArchivePosition { + readonly version: 1 + readonly archiveId: string + readonly sequence: number + readonly rowOffset: number +} + +/** A position within this retained view, not a durable checkpoint or authorization credential. */ export interface WalletSnapshotCursor { readonly version: 1 readonly snapshotId: string readonly table: WalletSnapshotTable readonly after: ReadonlyArray + readonly archivePosition?: WalletSnapshotArchivePosition } export interface WalletSnapshotPageLimits { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts index 9f70cb25e..0ad6b7a17 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts @@ -1,3 +1,4 @@ +import { snapshotArchiveReaderRequestId } from './SnapshotArchiveReaderRequest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -405,3 +406,256 @@ test.each([0, 1])('admission rechecks %i remaining milliseconds after ownership clock.mockRestore() } }) + +test('full-request cancellation fences a delayed first claim on another connection without reserving capacity', async () => { + const { db, requests, second } = await fixture() + const input = request() + await second.markCancellation(identity, input) + const closed = { version: 1, requestId: input.requestId, expiresAt: input.notAfter, state: 'closed' } + expect(await requests.claim(identity, input)).toEqual({ receipt: closed }) + expect(await second.status(identity, input.requestId)).toEqual(closed) + expect(await db('snapshot_archive_requests').first()).toMatchObject({ + state: 'closed', + released: 1, + reservedBytes: 0, + archiveId: null + }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await requests.markCancellation(identity, { ...input }) + await second.close(identity, input.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(1) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + const separate = await requests.claim(other, input) + expect(separate.owner).toBeDefined() + expect(separate.receipt.state).toBe('building') + await requests.close(other, input.requestId) +}) + +test('the cancellation fence retains an existing reservation until ordinary physical cleanup', async () => { + const { db, requests, second } = await fixture() + const input = request() + const admitted = await requests.claim(identity, input) + await second.markCancellation(identity, input) + expect((await requests.status(identity, input.requestId)).state).toBe('closed') + expect(await db('snapshot_archive_requests').first()).toMatchObject({ released: 0, reservedBytes: input.maxBytes }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: input.maxBytes }) + await expect(requests.begin(admitted.owner!, binding)).rejects.toThrow('unavailable') + await second.close(identity, input.requestId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('a cancellation tombstone expires at the original deadline and cannot make that request reusable', async () => { + const { db, requests, second } = await fixture() + const input = request() + await requests.markCancellation(identity, input) + const clock = jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(input.notAfter) + await second.reap() + expect(await db('snapshot_archive_requests')).toHaveLength(0) + await requests.markCancellation(identity, input) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + await expect(second.claim(identity, input)).rejects.toThrow('Invalid snapshot archive creation request') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + clock.mockRestore() +}) + +test('cancellation history exhaustion fails observably without falsely acknowledging a durable fence', async () => { + const { db, requests, second } = await fixture() + const retained = [] + for (let i = 0; i < 4; i++) { + const input = request(i.toString(16).padStart(64, '0')) + retained.push(input) + await requests.markCancellation(identity, input) + } + await expect(second.markCancellation(identity, request())).rejects.toThrow('history is occupied') + expect(await db('snapshot_archive_requests')).toHaveLength(4) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + // A retained request remains idempotently cancellable at the history ceiling. + await second.markCancellation(identity, retained[0]) + expect((await requests.claim(identity, retained[0])).receipt.state).toBe('closed') +}) + +test('failed cancellation persistence rolls back and never creates a partial reservation', async () => { + const { db, requests } = await fixture() + const input = request() + await db.raw( + "CREATE TRIGGER fail_request_cancel BEFORE INSERT ON snapshot_archive_requests WHEN NEW.state = 'closed' BEGIN SELECT RAISE(ABORT, 'synthetic cancellation interruption'); END" + ) + await expect(requests.markCancellation(identity, input)).rejects.toThrow('synthetic cancellation interruption') + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await db.raw('DROP TRIGGER fail_request_cancel') + await requests.markCancellation(identity, input) + expect((await requests.claim(identity, input)).receipt.state).toBe('closed') +}) + +// Append to RequestStore tests after implementing the offer. Uses real existing +// independent SQLite fixture and assertions against independently read tables. +test('server offers retain bounded cancellation ownership without charging a capture', async () => { + const { db, requests, second } = await fixture() + const options = { lifetimeMs: 300000, maxBytes: 32768 } + const issued = (await requests.offer(identity, options))! + expect(issued.request.notAfter).toBe(issued.serverTime + options.lifetimeMs) + expect(issued.request.maxBytes).toBe(options.maxBytes) + expect(issued.request.requestId).toBe(snapshotArchiveReaderRequestId(issued.request)) + expect(await db('snapshot_archive_requests').first()).toMatchObject({ + identityKey: identity, + state: 'offered', + reservedBytes: 0, + released: 1, + archiveId: null + }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + expect(await db('snapshot_archives')).toHaveLength(0) + await second.markReaderCancellation(identity, issued.request) + await second.close(identity, issued.request.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + await expect(requests.claimReader(identity, issued.request)).rejects.toThrow('unavailable') + await expect(requests.claim(identity, issued.request)).rejects.toThrow('Invalid snapshot archive creation request') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('a retained offer can be admitted when every history slot is occupied, charging exactly once', async () => { + const { db, requests, second } = await fixture() + const offers = [] + for (let n = 0; n < 4; n++) offers.push((await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))!) + expect(new Set(offers.map(offer => offer.request.requestId)).size).toBe(4) + expect(await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 })).toBeUndefined() + expect(await db('snapshot_archive_requests')).toHaveLength(4) + const admitted = await second.claimReader(identity, offers[0].request) + expect(admitted.owner).toBeDefined() + expect(await requests.claimReader(identity, offers[0].request)).toEqual({ receipt: admitted.receipt }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + await expect(requests.claimReader(identity, offers[1].request)).rejects.toThrow('occupied') + await requests.markReaderCancellation(identity, offers[1].request) + await requests.close(identity, offers[1].request.requestId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + await second.close(identity, offers[0].request.requestId) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('lost offers expire without source or capture quota and cannot be admitted after collection', async () => { + const { db, requests } = await fixture() + const clock = jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(1000000) + const issued = (await requests.offer(identity, { lifetimeMs: 50, maxBytes: 32768 }))! + clock.mockResolvedValue(1000050) + await requests.reap() + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await expect(requests.claimReader(identity, issued.request)).rejects.toThrow( + 'Invalid snapshot archive reader request' + ) +}) + +test('failed offered-to-claimed persistence rolls back quota and remains cancellable', async () => { + const { db, requests, second } = await fixture() + const issued = (await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + await db.raw( + "CREATE TRIGGER fail_offer_claim BEFORE UPDATE ON snapshot_archive_requests WHEN NEW.state = 'claimed' BEGIN SELECT RAISE(ABORT, 'synthetic offered admission interruption'); END" + ) + await expect(second.claimReader(identity, issued.request)).rejects.toThrow('synthetic offered admission interruption') + expect(await db('snapshot_archive_requests').first()).toMatchObject({ + state: 'offered', + released: 1, + reservedBytes: 0 + }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await db.raw('DROP TRIGGER fail_offer_claim') + await requests.markReaderCancellation(identity, issued.request) + await requests.close(identity, issued.request.requestId) + await expect(second.claimReader(identity, issued.request)).rejects.toThrow('unavailable') + expect(await db('snapshot_archive_requests')).toHaveLength(0) +}) + +test('frequent completed readers release history immediately without reopening delayed requests', async () => { + const { db, requests, second } = await fixture() + const old = [] + for (let n = 0; n < 16; n++) { + const offer = (await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + old.push(offer.request) + const admitted = await requests.claimReader(identity, offer.request) + expect(admitted.owner).toBeDefined() + await second.markReaderCancellation(identity, offer.request) + await second.close(identity, offer.request.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + } + for (const request of old) { + await expect(requests.claimReader(identity, request)).rejects.toThrow('unavailable') + await expect(requests.claim(identity, request)).rejects.toThrow('Invalid snapshot archive creation request') + await expect(second.markReaderCancellation(identity, request)).resolves.toBeUndefined() + } + expect(await db('snapshot_archive_requests')).toHaveLength(0) +}) + +test('a caller cannot admit a reader tuple that the server never offered', async () => { + const { db, requests } = await fixture() + const fields = { version: 2 as const, nonce: 'a'.repeat(64), notAfter: Date.now() + 300000, maxBytes: 32768 } + const input = { ...fields, requestId: snapshotArchiveReaderRequestId(fields) } + await expect(requests.claimReader(identity, input)).rejects.toThrow('unavailable') + await expect(requests.claim(identity, input)).rejects.toThrow('Invalid snapshot archive creation request') + await expect(requests.markReaderCancellation(identity, input)).resolves.toBeUndefined() + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('reader cleanup keeps its reservation and fence through a failed page deletion', async () => { + const { db, requests, second, archives } = await fixture() + const offered = (await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + const admitted = await requests.claimReader(identity, offered.request) + const writer = await requests.begin(admitted.owner!, binding) + await append(archives, writer) + await requests.seal(admitted.owner!, writer) + await db.raw( + "CREATE TRIGGER fail_reader_cleanup BEFORE DELETE ON snapshot_archive_pages BEGIN SELECT RAISE(ABORT, 'synthetic reader cleanup interruption'); END" + ) + await expect(second.close(identity, offered.request.requestId)).rejects.toThrow( + 'synthetic reader cleanup interruption' + ) + expect(await db('snapshot_archive_requests').first()).toMatchObject({ + state: 'closed', + released: 0, + archiveId: writer.archiveId + }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + expect(await requests.claimReader(identity, offered.request)).toEqual({ + receipt: { version: 1, requestId: offered.request.requestId, expiresAt: offered.request.notAfter, state: 'closed' } + }) + expect(await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 })).toBeUndefined() + await db.raw('DROP TRIGGER fail_reader_cleanup') + await second.close(identity, offered.request.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await expect(requests.claimReader(identity, offered.request)).rejects.toThrow('unavailable') +}) + +test('reader offers remain profile-bound through claim and cancellation', async () => { + const { db, requests, second } = await fixture() + const issued = (await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + await expect(second.claimReader(other, issued.request)).rejects.toThrow('unavailable') + await second.markReaderCancellation(other, issued.request) + expect(await db('snapshot_archive_requests')).toHaveLength(1) + expect(await db('snapshot_archive_requests').first()).toMatchObject({ identityKey: identity, state: 'offered' }) + expect((await requests.claimReader(identity, issued.request)).owner).toBeDefined() + await second.close(identity, issued.request.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(0) +}) + +test('early reader collection refuses altered persisted tuple binding and rolls back release', async () => { + const { db, requests } = await fixture() + const issued = (await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + await requests.claimReader(identity, issued.request) + await db('snapshot_archive_requests').update({ + requestJson: JSON.stringify({ ...issued.request, requestId: '0'.repeat(64) }) + }) + await expect(requests.close(identity, issued.request.requestId)).rejects.toThrow( + 'Invalid snapshot archive reader request' + ) + expect(await db('snapshot_archive_requests').first()).toMatchObject({ state: 'closed', released: 0 }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + await db('snapshot_archive_requests').update({ requestJson: JSON.stringify(issued.request) }) + await requests.close(identity, issued.request.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts index 1b328566f..4391f6c3d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts @@ -3,6 +3,14 @@ import type { Knex } from 'knex' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' import { runInSeries } from '../../../utility/runInSeries' import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { SnapshotArchiveAdmissionLimitError } from './SnapshotArchiveAdmission' +import { validateSnapshotArchiveReaderOptions, type SnapshotArchiveReaderOptions } from './SnapshotArchiveReaderOffer' +import { + parseSnapshotArchiveReaderRequest, + validateSnapshotArchiveReaderRequest, + snapshotArchiveReaderRequestId, + type SnapshotArchiveReaderRequest +} from './SnapshotArchiveReaderRequest' import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' import { snapshotArchiveLimits, @@ -14,6 +22,7 @@ import { lockSnapshotArchiveCapacity, snapshotArchiveDatabaseNow } from './Snaps import { parseSnapshotArchiveRequest, validateSnapshotArchiveRequest, + type SnapshotArchiveRequest, type SnapshotArchiveRequestOwner, type SnapshotArchiveRequestReceipt, type SnapshotArchiveTerminalState @@ -23,7 +32,7 @@ interface RequestRow { identityKey: string requestId: string claimToken: string - state: 'claimed' | 'capturing' | 'ready' | SnapshotArchiveTerminalState + state: 'offered' | 'claimed' | 'capturing' | 'ready' | SnapshotArchiveTerminalState requestJson: string expiresAt: number | string reservedBytes: number | string @@ -62,7 +71,59 @@ export class KnexSnapshotArchiveRequestStore { const archive = await new KnexSnapshotArchiveStore(k).inspect(row.identityKey, row.archiveId) return { ...base, state: 'ready', archiveId: archive.archiveId, digest: archive.digest } } - return { ...base, state: row.state === 'claimed' || row.state === 'capturing' ? 'building' : row.state } + if (row.state === 'offered' || row.state === 'claimed' || row.state === 'capturing') + return { ...base, state: 'building' } + return { ...base, state: row.state } + } + + async offer( + identityKey: string, + input: SnapshotArchiveReaderOptions + ): Promise<{ serverTime: number; request: Readonly } | undefined> { + identity(identityKey) + const options = validateSnapshotArchiveReaderOptions(input) + return await this.knex.transaction(async trx => { + const capacity = await lockSnapshotArchiveCapacity(trx) + const now = await snapshotArchiveDatabaseNow(trx) + await trx(table).where('expiresAt', '<=', now).where({ released: true }).delete() + const retained: Array> = await trx(table) + .select('identityKey', 'released') + .limit(snapshotArchiveRequestLimits.total) + const owned = retained.filter(row => row.identityKey === identityKey) + const archive = await trx('snapshot_archives').where({ identityKey }).first('archiveId') + if ( + retained.length >= snapshotArchiveRequestLimits.total || + owned.length >= snapshotArchiveRequestLimits.perProfile || + owned.some(row => !row.released) || + archive !== undefined || + capacity.archives >= snapshotArchiveLimits.archives || + Number(capacity.reservedBytes) + options.maxBytes > snapshotArchiveLimits.totalBytes + ) + return undefined + const fields = { + version: 2 as const, + nonce: Utils.toHex(Random(32)), + notAfter: now + options.lifetimeMs, + maxBytes: options.maxBytes + } + const request = validateSnapshotArchiveReaderRequest( + { ...fields, requestId: snapshotArchiveReaderRequestId(fields) }, + now + ) + const row: RequestRow = { + identityKey, + requestId: request.requestId, + claimToken: '', + state: 'offered', + requestJson: JSON.stringify(request), + expiresAt: request.notAfter, + reservedBytes: 0, + archiveId: null, + released: true + } + await trx(table).insert(row) + return { serverTime: now, request } + }) } async claim( @@ -70,18 +131,37 @@ export class KnexSnapshotArchiveRequestStore { input: unknown ): Promise<{ receipt: SnapshotArchiveRequestReceipt; owner?: SnapshotArchiveRequestOwner }> { identity(identityKey) - const request = parseSnapshotArchiveRequest(input) + return await this.claimRequest(identityKey, parseSnapshotArchiveRequest(input), false) + } + + /** Admission never creates an absent reader offer, including after completed cleanup. */ + async claimReader( + identityKey: string, + input: unknown + ): Promise<{ receipt: SnapshotArchiveRequestReceipt; owner?: SnapshotArchiveRequestOwner }> { + identity(identityKey) + return await this.claimRequest(identityKey, parseSnapshotArchiveReaderRequest(input), true) + } + + private async claimRequest( + identityKey: string, + request: Readonly, + reader: boolean + ): Promise<{ receipt: SnapshotArchiveRequestReceipt; owner?: SnapshotArchiveRequestOwner }> { const requestJson = JSON.stringify(request) return await this.knex.transaction(async trx => { const capacity = await lockSnapshotArchiveCapacity(trx) const now = await snapshotArchiveDatabaseNow(trx) - validateSnapshotArchiveRequest(request, now) + if (reader) validateSnapshotArchiveReaderRequest(request, now) + else validateSnapshotArchiveRequest(request, now) const existing: RequestRow | undefined = await trx(table) .where({ identityKey, requestId: request.requestId }) .first() if (existing !== undefined) { if (existing.requestJson !== requestJson) unavailable() - return { receipt: await this.receipt(trx, existing) } + if (!reader || existing.state !== 'offered') return { receipt: await this.receipt(trx, existing) } + } else if (reader) { + unavailable() } await trx(table).where('expiresAt', '<=', now).where({ released: true }).delete() const retained: Array> = await trx(table) @@ -90,14 +170,15 @@ export class KnexSnapshotArchiveRequestStore { const owned = retained.filter(row => row.identityKey === identityKey) const archive = await trx('snapshot_archives').where({ identityKey }).first('archiveId') if ( - retained.length >= snapshotArchiveRequestLimits.total || - owned.length >= snapshotArchiveRequestLimits.perProfile || + (!reader && + (retained.length >= snapshotArchiveRequestLimits.total || + owned.length >= snapshotArchiveRequestLimits.perProfile)) || owned.some(row => !row.released) || archive !== undefined || capacity.archives >= snapshotArchiveLimits.archives || Number(capacity.reservedBytes) + request.maxBytes > snapshotArchiveLimits.totalBytes ) - throw new SnapshotResourceLimitError('Snapshot archive request capacity is occupied') + throw new SnapshotArchiveAdmissionLimitError('Snapshot archive request capacity is occupied') const owner = { identityKey, requestId: request.requestId, claimToken: Utils.toHex(Random(32)) } const row: RequestRow = { ...owner, @@ -108,7 +189,8 @@ export class KnexSnapshotArchiveRequestStore { archiveId: null, released: false } - await trx(table).insert(row) + if (reader) await trx(table).where({ identityKey, requestId: request.requestId }).update(row) + else await trx(table).insert(row) await trx('snapshot_archive_capacity') .where({ id: 1 }) .update({ archives: capacity.archives + 1, reservedBytes: Number(capacity.reservedBytes) + request.maxBytes }) @@ -170,13 +252,85 @@ export class KnexSnapshotArchiveRequestStore { }) } + /** Persist a bounded terminal fence before a delayed first admission can reserve resources. */ + async markCancellation(identityKey: string, input: unknown): Promise { + identity(identityKey) + const request = parseSnapshotArchiveRequest(input) + const requestJson = JSON.stringify(request) + await this.knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + const key = { identityKey, requestId: request.requestId } + const existing: RequestRow | undefined = await trx(table).where(key).first() + if (existing !== undefined) { + if (existing.requestJson !== requestJson) unavailable() + if (['claimed', 'capturing', 'ready'].includes(existing.state)) + await trx(table).where(key).update({ state: 'closed' }) + return + } + const now = await snapshotArchiveDatabaseNow(trx) + // The immutable deadline already prevents a future first admission. + if (request.notAfter <= now) return + validateSnapshotArchiveRequest(request, now) + await trx(table).where('expiresAt', '<=', now).where({ released: true }).delete() + const retained: Array> = await trx(table) + .select('identityKey') + .limit(snapshotArchiveRequestLimits.total) + if ( + retained.length >= snapshotArchiveRequestLimits.total || + retained.filter(row => row.identityKey === identityKey).length >= snapshotArchiveRequestLimits.perProfile + ) + throw new SnapshotResourceLimitError('Snapshot archive cancellation history is occupied') + await trx(table).insert({ + ...key, + claimToken: Utils.toHex(Random(32)), + state: 'closed', + requestJson, + expiresAt: request.notAfter, + reservedBytes: 0, + archiveId: null, + released: true + }) + }) + } + + /** Fence only the retained exact reader tuple; absence cannot start another capture. */ + async markReaderCancellation(identityKey: string, input: unknown): Promise { + identity(identityKey) + const request = parseSnapshotArchiveReaderRequest(input) + await this.knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + const key = { identityKey, requestId: request.requestId } + const row: RequestRow | undefined = await trx(table).where(key).first() + if (row === undefined) return + if (row.requestJson !== JSON.stringify(request)) unavailable() + // Explicit client cancellation acknowledges a terminal failure receipt too. + // Until then, polling must still observe failed/resource-limited capture. + await trx(table).where(key).update({ state: 'closed' }) + }) + } + + /** Retain failure receipts for polling; only closed/expired reader tuples may be collected early. */ + private async collectReader(k: Knex, row: RequestRow): Promise { + if (!row.released || !['closed', 'expired'].includes(row.state)) return + const decoded: unknown = JSON.parse(row.requestJson) + if ( + decoded === null || + typeof decoded !== 'object' || + Object.getOwnPropertyDescriptor(decoded, 'version')?.value !== 2 + ) + return + const request = parseSnapshotArchiveReaderRequest(decoded) + if (request.requestId !== row.requestId || request.notAfter !== Number(row.expiresAt)) unavailable() + await k(table).where({ identityKey: row.identityKey, requestId: row.requestId, released: true }).delete() + } + async close(identityKey: string, requestId: string, state: SnapshotArchiveTerminalState = 'closed'): Promise { identity(identityKey) identifier(requestId) await this.knex.transaction(async trx => { await lockSnapshotArchiveCapacity(trx) const row: RequestRow | undefined = await trx(table).where({ identityKey, requestId }).first() - if (row !== undefined && ['claimed', 'capturing', 'ready'].includes(row.state)) { + if (row !== undefined && ['offered', 'claimed', 'capturing', 'ready'].includes(row.state)) { await trx(table).where({ identityKey, requestId }).update({ state }) } }) @@ -187,8 +341,12 @@ export class KnexSnapshotArchiveRequestStore { const archiveId = await this.knex.transaction(async trx => { const capacity = await lockSnapshotArchiveCapacity(trx) const row: RequestRow | undefined = await trx(table).where({ identityKey, requestId }).first() - if (row === undefined || row.released || !['closed', 'failed', 'expired', 'resource-limited'].includes(row.state)) + if (row === undefined || !['closed', 'failed', 'expired', 'resource-limited'].includes(row.state)) return undefined + if (row.released) { + await this.collectReader(trx, row) + return undefined + } if (row.archiveId !== null) return row.archiveId await trx('snapshot_archive_capacity') .where({ id: 1 }) @@ -197,13 +355,17 @@ export class KnexSnapshotArchiveRequestStore { reservedBytes: Number(capacity.reservedBytes) - Number(row.reservedBytes) }) await trx(table).where({ identityKey, requestId }).update({ released: true }) + await this.collectReader(trx, { ...row, released: true }) return undefined }) if (archiveId === undefined) return await new KnexSnapshotArchiveStore(this.knex).close(identityKey, archiveId) await this.knex.transaction(async trx => { await lockSnapshotArchiveCapacity(trx) + const row: RequestRow | undefined = await trx(table).where({ identityKey, requestId, archiveId }).first() + if (row === undefined) return await trx(table).where({ identityKey, requestId, archiveId }).update({ released: true }) + await this.collectReader(trx, { ...row, released: true }) }) } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts index 1c95e34e1..a905197e1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts @@ -1,3 +1,4 @@ +import type { SnapshotArchiveReaderOffer } from './SnapshotArchiveReaderOffer' import { WERR_INVALID_OPERATION, WERR_UNAUTHORIZED } from '../../../sdk/WERR_errors' import type { StorageKnex } from '../../StorageKnex' import { KnexSnapshotArchiveService } from './KnexSnapshotArchiveService' @@ -38,6 +39,20 @@ export class KnexSnapshotArchiveRpc { return this.stopped ? undefined : snapshotArchiveCapabilities } + private async sourceOffer(serverTime?: number): Promise { + const chain = this.storage.chain + if (chain !== 'main' && chain !== 'test') throw new WERR_INVALID_OPERATION('Snapshot archive chain is unavailable') + const sourceSchema = await readSnapshotArchiveSourceSchema(this.storage, this.storage.knex) + const settings = this.storage.getSettings() + return { + version: 1, + sourceStorageIdentityKey: settings.storageIdentityKey, + sourceSchema, + chain, + serverTime: serverTime ?? (await snapshotArchiveDatabaseNow(this.storage.knex)) + } + } + async dispatch(method: SnapshotArchiveMethod, params: unknown[], authenticatedIdentityKey: string): Promise { const input = parseSnapshotArchiveRpcInput(method, params) if (input.identityKey !== authenticatedIdentityKey) @@ -46,21 +61,27 @@ export class KnexSnapshotArchiveRpc { throw new WERR_INVALID_OPERATION('Snapshot archive transport is unavailable') const identityKey = authenticatedIdentityKey switch (input.method) { - case 'getSnapshotArchiveOffer': { - const chain = this.storage.chain - if (chain !== 'main' && chain !== 'test') - throw new WERR_INVALID_OPERATION('Snapshot archive chain is unavailable') - const sourceSchema = await readSnapshotArchiveSourceSchema(this.storage, this.storage.knex) - const settings = this.storage.getSettings() - const result: SnapshotArchiveOffer = { + case 'getSnapshotArchiveReaderOffer': { + // Read source metadata before issuing a retained request, so failed metadata + // cannot leave a caller-owned request that was never returned. + const offer = await this.sourceOffer() + const issued = await this.service.offerReader(identityKey, input.options) + if (issued === undefined) + return { version: 1, outcome: 'resource-limited' } satisfies SnapshotArchiveReaderOffer + return { version: 1, - sourceStorageIdentityKey: settings.storageIdentityKey, - sourceSchema, - chain, - serverTime: await snapshotArchiveDatabaseNow(this.storage.knex) - } - return result + outcome: 'offered', + offer: { ...offer, serverTime: issued.serverTime }, + request: issued.request + } satisfies SnapshotArchiveReaderOffer } + case 'admitSnapshotArchive': + return await this.service.admitReader(identityKey, input.request) + case 'cancelSnapshotArchiveRequest': + await this.service.cancelReader(identityKey, input.request) + return true + case 'getSnapshotArchiveOffer': + return await this.sourceOffer() case 'startSnapshotArchive': return await this.service.start(identityKey, input.request) case 'getSnapshotArchiveStatus': diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts index 3f5a76906..af5c7514b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts @@ -446,3 +446,151 @@ test.each(['pending', 'capturing', 'ready', 'legacy'] as const)( expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) } ) + +test('a second controller fences a first start delayed before its durable claim', async () => { + const { storage, controller, open } = await fixture() + const replacementStorage = open() + await replacementStorage.makeAvailable() + const replacement = service(replacementStorage) + const input = request() + const entered = gate() + const resume = gate() + const original = KnexSnapshotArchiveRequestStore.prototype.claim + jest.spyOn(KnexSnapshotArchiveRequestStore.prototype, 'claim').mockImplementation(async function ( + this: KnexSnapshotArchiveRequestStore, + key, + value + ) { + entered.resolve() + await resume.promise + return await original.call(this, key, value) + }) + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource') + const pending = controller.create(identity, input) + void pending.catch(() => undefined) + try { + await entered.promise + // Client-side abort alone cannot drain this delayed server operation. + await replacement.cancelRequest(identity, input) + await replacement.cancelRequest(identity, input) + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + resume.resolve() + expect((await pending).state).toBe('closed') + expect(opening).not.toHaveBeenCalled() + expect(await storage.knex('snapshot_archive_pages')).toHaveLength(0) + expect((await replacement.create(identity, input)).state).toBe('closed') + } finally { + resume.resolve() + await pending.catch(() => undefined) + } +}) + +test('reader offers acquire no source and a replacement recovers only the admitted immutable archive', async () => { + const { storage, controller, open } = await fixture() + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource') + const offered = (await controller.offerReader(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + expect(offered.request.version).toBe(2) + expect(opening).not.toHaveBeenCalled() + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await expect(controller.admitReader(other, offered.request)).rejects.toThrow('unavailable') + const admitted = await controller.admitReader(identity, offered.request) + expect(admitted).toMatchObject({ outcome: 'accepted', receipt: { state: 'building' } }) + let receipt = await controller.status(identity, offered.request.requestId) + for (let attempt = 0; receipt.state === 'building' && attempt < 100; attempt++) { + await new Promise(resolve => setTimeout(resolve, 10)) + receipt = await controller.status(identity, offered.request.requestId) + } + expect(receipt.state).toBe('ready') + expect(opening).toHaveBeenCalledTimes(1) + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() + const directory = await controller.directory(identity, receipt.archiveId!) + await controller.close() + const replacementStorage = open() + await replacementStorage.makeAvailable() + const replacement = service(replacementStorage) + const replacementOpening = jest.spyOn(replacementStorage, 'openSnapshotArchiveSource') + expect(await replacement.admitReader(identity, offered.request)).toEqual({ + version: 1, + outcome: 'accepted', + receipt + }) + expect(await replacement.directory(identity, receipt.archiveId!)).toEqual(directory) + expect(replacementOpening).not.toHaveBeenCalled() + await replacement.cancelReader(identity, offered.request) + await replacement.cancelReader(identity, offered.request) + expect(await storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect(await storage.knex('snapshot_archive_pages')).toHaveLength(0) + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await expect(replacement.admitReader(identity, offered.request)).rejects.toThrow('unavailable') + expect(replacementOpening).not.toHaveBeenCalled() +}) + +test('reader cancellation on a replacement removes an offer before a delayed admission can capture', async () => { + const { storage, controller, open } = await fixture() + const offered = (await controller.offerReader(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + const replacementStorage = open() + await replacementStorage.makeAvailable() + const replacement = service(replacementStorage) + const entered = gate() + const resume = gate() + const original = KnexSnapshotArchiveRequestStore.prototype.claimReader + jest.spyOn(KnexSnapshotArchiveRequestStore.prototype, 'claimReader').mockImplementation(async function ( + this: KnexSnapshotArchiveRequestStore, + key, + value + ) { + entered.resolve() + await resume.promise + return await original.call(this, key, value) + }) + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource') + const pending = controller.admitReader(identity, offered.request) + void pending.catch(() => undefined) + try { + await entered.promise + await replacement.cancelReader(identity, offered.request) + await replacement.cancelReader(identity, offered.request) + expect(await storage.knex('snapshot_archive_requests')).toHaveLength(0) + resume.resolve() + await expect(pending).rejects.toThrow('unavailable') + expect(opening).not.toHaveBeenCalled() + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + expect(await storage.knex('snapshot_archive_pages')).toHaveLength(0) + await expect(replacement.admitReader(identity, offered.request)).rejects.toThrow('unavailable') + expect(opening).not.toHaveBeenCalled() + } finally { + resume.resolve() + await pending.catch(() => undefined) + } +}) + +test.each(['failed', 'resource-limited'] as const)( + 'reader %s capture remains observable until explicit cancellation collects its receipt', + async state => { + const { storage, controller, open } = await fixture() + const offered = (await controller.offerReader(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + const failure = state === 'failed' ? new Error('synthetic capture failure') : new SnapshotResourceLimitError('full') + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource').mockRejectedValueOnce(failure) + expect(await controller.admitReader(identity, offered.request)).toMatchObject({ outcome: 'accepted' }) + let receipt = await controller.status(identity, offered.request.requestId) + for (let attempt = 0; receipt.state === 'building' && attempt < 100; attempt++) { + await new Promise(resolve => setTimeout(resolve, 10)) + receipt = await controller.status(identity, offered.request.requestId) + } + expect(receipt.state).toBe(state) + await controller.close() + const replacementStorage = open() + await replacementStorage.makeAvailable() + const replacement = service(replacementStorage) + expect(await replacement.admitReader(identity, offered.request)).toEqual({ + version: 1, + outcome: 'accepted', + receipt + }) + expect(opening).toHaveBeenCalledTimes(1) + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await replacement.cancelReader(identity, offered.request) + expect(await storage.knex('snapshot_archive_requests')).toHaveLength(0) + await expect(replacement.admitReader(identity, offered.request)).rejects.toThrow('unavailable') + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts index 9a2f9f597..0c738703b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts @@ -1,6 +1,9 @@ +import { parseSnapshotArchiveReaderRequest, type SnapshotArchiveReaderRequest } from './SnapshotArchiveReaderRequest' +import { validateSnapshotArchiveReaderOptions, type SnapshotArchiveReaderOptions } from './SnapshotArchiveReaderOffer' import { WERR_INVALID_OPERATION, WERR_NOT_IMPLEMENTED } from '../../../sdk/WERR_errors' import type { StorageKnex } from '../../StorageKnex' import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { SnapshotArchiveAdmissionLimitError, type SnapshotArchiveAdmission } from './SnapshotArchiveAdmission' import { SnapshotArchiveSourceCleanupError, type SnapshotArchiveSource } from './KnexSnapshotArchiveSource' import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' @@ -16,7 +19,7 @@ import { assertSnapshotArchiveCaptureActive, captureSnapshotArchiveSource } from interface Capture { identityKey: string - request: Readonly + request: Readonly controller: AbortController terminal: Exclude accepted: Promise @@ -61,13 +64,49 @@ export class KnexSnapshotArchiveService { return this.admit(identityKey, input).accepted } - private admit(identityKey: string, input: unknown): Capture { - this.assertOpen() + /** Only a pre-admission capacity refusal selects the transient outcome. */ + async admitRequest(identityKey: string, input: unknown): Promise { const request = parseSnapshotArchiveRequest(input) + try { + return { version: 1, outcome: 'accepted', receipt: await this.start(identityKey, request) } + } catch (error) { + if (!(error instanceof SnapshotArchiveAdmissionLimitError)) throw error + return { version: 1, outcome: 'resource-limited', requestId: request.requestId, expiresAt: request.notAfter } + } + } + + /** Offering retains metadata only; no SQL source or archive quota is acquired. */ + async offerReader(identityKey: string, input: SnapshotArchiveReaderOptions) { + this.assertOpen() + const options = validateSnapshotArchiveReaderOptions(input) + if (this.active !== undefined) return undefined + await this.requests.reap() + this.assertOpen() + await this.archives.reap() + this.assertOpen() + const offer = await this.requests.offer(identityKey, options) + if (this.stopped && offer !== undefined) await this.requests.close(identityKey, offer.request.requestId) + this.assertOpen() + return offer + } + + async admitReader(identityKey: string, input: unknown): Promise { + const request = parseSnapshotArchiveReaderRequest(input) + try { + return { version: 1, outcome: 'accepted', receipt: await this.admit(identityKey, request, true).accepted } + } catch (error) { + if (!(error instanceof SnapshotArchiveAdmissionLimitError)) throw error + return { version: 1, outcome: 'resource-limited', requestId: request.requestId, expiresAt: request.notAfter } + } + } + + private admit(identityKey: string, input: unknown, reader = false): Capture { + this.assertOpen() + const request = reader ? parseSnapshotArchiveReaderRequest(input) : parseSnapshotArchiveRequest(input) if (this.active !== undefined) { if (this.active.identityKey === identityKey && this.active.request.requestId === request.requestId) return this.active - throw new SnapshotResourceLimitError('Snapshot archive capture is opening or active') + throw new SnapshotArchiveAdmissionLimitError('Snapshot archive capture is opening or active') } const claim = Promise.resolve().then(async () => { assertSnapshotArchiveCaptureActive(job.controller.signal) @@ -75,7 +114,9 @@ export class KnexSnapshotArchiveService { await this.requests.reap() await this.archives.reap() assertSnapshotArchiveCaptureActive(job.controller.signal) - return await this.requests.claim(identityKey, request) + return reader + ? await this.requests.claimReader(identityKey, request) + : await this.requests.claim(identityKey, request) }) const accepted = claim.then(result => Object.freeze({ ...result.receipt })) const completion = claim @@ -167,6 +208,27 @@ export class KnexSnapshotArchiveService { if (this.cleanupFailure !== undefined) throw this.cleanupFailure.error } + /** Fence the immutable request even if cancellation precedes its first claim. */ + async cancelRequest(identityKey: string, input: unknown): Promise { + this.assertOpen() + const request = parseSnapshotArchiveRequest(input) + await this.requests.markCancellation(identityKey, request) + const job = this.active + if (job?.identityKey === identityKey && job.request.requestId === request.requestId) await this.stop(job) + // markCancellation does not release an existing physical owner's reservation. + // stop drains this process first; then the normal cleanup path is resumable. + await this.requests.close(identityKey, request.requestId) + } + + async cancelReader(identityKey: string, input: unknown): Promise { + this.assertOpen() + const request = parseSnapshotArchiveReaderRequest(input) + await this.requests.markReaderCancellation(identityKey, request) + const job = this.active + if (job?.identityKey === identityKey && job.request.requestId === request.requestId) await this.stop(job) + await this.requests.close(identityKey, request.requestId) + } + async cancel(identityKey: string, requestId: string): Promise { this.assertOpen() const job = this.active diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts new file mode 100644 index 000000000..587a0d9f1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts @@ -0,0 +1,303 @@ +import { RemoteSnapshotLease } from './RemoteSnapshotLease' +import { remoteReaderFixture } from '../../../../test/utils/remoteSnapshotReaderFixtures' +import { snapshotArchiveReaderRequestId } from './SnapshotArchiveReaderRequest' +import { SnapshotCancelledError } from '../SnapshotCancelledError' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { SnapshotArchiveTransportFailure } from './SnapshotArchiveTransportFailure' + +function request() { + const fields = { version: 2 as const, nonce: 'a'.repeat(64), notAfter: Date.now() + 300000, maxBytes: 32768 } + return { ...fields, requestId: snapshotArchiveReaderRequestId(fields) } +} +function gate() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} +afterEach(() => { + jest.restoreAllMocks() + jest.useRealTimers() +}) + +test.each([0, -1, 3600001, 1.5, NaN, Infinity])('rejects invalid lifetime %p before retaining a timer', lifetimeMs => { + jest.useFakeTimers() + const { transport } = remoteReaderFixture([]) + expect(() => new RemoteSnapshotLease(transport, { lifetimeMs })).toThrow('an integer from 1 to 3600000') + expect(jest.getTimerCount()).toBe(0) +}) + +test('server time includes the entire offer round trip and wall-clock rollback cannot extend the lease', async () => { + jest.useFakeTimers({ now: 1790812800000 }) + const { transport } = remoteReaderFixture([]) + const lease = new RemoteSnapshotLease(transport, { lifetimeMs: 1000 }) + expect(() => lease.now()).toThrow('clock is not bound') + await jest.advanceTimersByTimeAsync(200) + lease.bindServerTime(100000) + expect(lease.now()).toBe(100200) + expect(() => lease.bindServerTime(100001)).toThrow('clock is already bound') + expect(lease.expiresAt).toBe(1790812801000) + jest.setSystemTime(1790812800000 - 3600000) + await jest.advanceTimersByTimeAsync(800) + expect(lease.isOpen).toBe(false) + expect(() => lease.now()).toThrow('expired') + await lease.closed + expect(jest.getTimerCount()).toBe(0) +}) + +test('close fences synchronously, drains the client operation and cancels the exact detached immutable tuple', async () => { + const { transport, rpc } = remoteReaderFixture([]) + const lease = new RemoteSnapshotLease(transport, {}) + const original = request() + const expected = { ...original } + lease.own(original) + original.nonce = 'b'.repeat(64) + expect(() => lease.own(request())).toThrow('already owned') + const entered = gate() + const resume = gate() + let signal: AbortSignal | undefined + const pending = lease.run(async current => { + signal = current + entered.resolve() + await resume.promise + return 1 + }) + void pending.catch(() => undefined) + try { + await entered.promise + await expect(lease.run(async () => 2)).rejects.toThrow('already has an operation') + let closed = false + const closing = lease.close() + void closing.then(() => { + closed = true + }) + expect(lease.close()).toBe(closing) + expect(lease.isOpen).toBe(false) + expect(signal!.aborted).toBe(true) + await Promise.resolve() + expect(closed).toBe(false) + expect(rpc).not.toHaveBeenCalled() + resume.resolve() + await expect(pending).rejects.toThrow('closed') + await closing + expect(closed).toBe(true) + expect(rpc).toHaveBeenCalledWith( + 'cancelSnapshotArchiveRequest', + [expect.objectContaining({ request: expected })], + undefined + ) + expect(rpc).toHaveBeenCalledTimes(1) + } finally { + resume.resolve() + await pending.catch(() => undefined) + await lease.close() + } +}) + +test('cancellation during polling drains the wait and clears both deadline and delay timers', async () => { + jest.useFakeTimers() + const { transport, rpc } = remoteReaderFixture([]) + const controller = new AbortController() + const lease = new RemoteSnapshotLease(transport, { signal: controller.signal }) + lease.own(request()) + const pending = lease.wait(1000) + void pending.catch(() => undefined) + await Promise.resolve() + controller.abort() + await expect(pending).rejects.toThrow('cancelled') + await lease.closed + expect(jest.getTimerCount()).toBe(0) + expect(rpc).toHaveBeenCalledTimes(1) + expect(lease.isOpen).toBe(false) +}) + +test('failed remote cancellation remains observable through close and closed', async () => { + const { transport } = remoteReaderFixture([]) + const error = new Error('synthetic durable cancellation failure') + jest.spyOn(transport, 'cancelRequest').mockRejectedValue(error) + const lease = new RemoteSnapshotLease(transport, {}) + lease.own(request()) + const closing = lease.close() + await expect(closing).rejects.toBe(error) + await expect(lease.closed).rejects.toBe(error) + expect(lease.close()).toBe(closing) + expect(lease.isOpen).toBe(false) +}) + +test('already-aborted acquisition never starts a request or leaves a timer behind', async () => { + jest.useFakeTimers() + const { transport, rpc } = remoteReaderFixture([]) + const controller = new AbortController() + controller.abort() + const lease = new RemoteSnapshotLease(transport, { signal: controller.signal }) + await expect(lease.run(async () => 1)).rejects.toThrow('cancelled') + await lease.closed + expect(rpc).not.toHaveBeenCalled() + expect(jest.getTimerCount()).toBe(0) +}) + +test.each(['native', 'sdk', 'validation'] as const)( + 'lease expiry classifies %s transport settlement without suppressing independent validation failures', + async kind => { + jest.useFakeTimers() + const { transport } = remoteReaderFixture([]) + const lease = new RemoteSnapshotLease(transport, { lifetimeMs: 10 }) + const entered = gate() + const resume = gate() + const failure = + kind === 'native' + ? new SnapshotArchiveTransportFailure(new Error('synthetic connection loss')) + : kind === 'sdk' + ? new SnapshotCancelledError('synthetic authenticated request cancelled') + : new Error('synthetic authentication failure') + const pending = lease.run(async () => { + entered.resolve() + await resume.promise + throw failure + }) + void pending.catch(() => undefined) + try { + await entered.promise + await jest.advanceTimersByTimeAsync(10) + expect(lease.isOpen).toBe(false) + resume.resolve() + if (kind === 'validation') await expect(pending).rejects.toBe(failure) + else await expect(pending).rejects.toBeInstanceOf(SnapshotResourceLimitError) + await lease.closed + expect(jest.getTimerCount()).toBe(0) + } finally { + resume.resolve() + await pending.catch(() => undefined) + await lease.close() + } + } +) + +test('immutable operation recovery retains one slot, signal and fixed deadline', async () => { + const { transport } = remoteReaderFixture([]) + const lease = new RemoteSnapshotLease(transport, {}) + const entered = gate() + const resume = gate() + const signals: AbortSignal[] = [] + const expiresAt = lease.expiresAt + const operation = jest.fn(async (signal: AbortSignal) => { + signals.push(signal) + if (signals.length === 1) throw new SnapshotArchiveTransportFailure('synthetic loss') + entered.resolve() + await resume.promise + return 7 + }) + const pending = lease.runIdempotent(operation) + try { + await entered.promise + expect(operation).toHaveBeenCalledTimes(2) + expect(signals[0]).toBe(signals[1]) + expect(signals[0].aborted).toBe(false) + expect(lease.expiresAt).toBe(expiresAt) + await expect(lease.run(async () => 2)).rejects.toThrow('already has an operation') + resume.resolve() + await expect(pending).resolves.toBe(7) + } finally { + resume.resolve() + await pending.catch(() => undefined) + await lease.close() + } +}) + +test.each(['cancelled', 'expired'] as const)('a %s lease never starts its connection-loss retry', async kind => { + jest.useFakeTimers() + const { transport } = remoteReaderFixture([]) + const controller = new AbortController() + const lease = new RemoteSnapshotLease(transport, { lifetimeMs: 10, signal: controller.signal }) + const operation = jest.fn(async () => { + if (kind === 'cancelled') controller.abort() + else jest.setSystemTime(Date.now() + 10) + throw new SnapshotArchiveTransportFailure('synthetic loss') + }) + await expect(lease.runIdempotent(operation)).rejects.toThrow(kind) + expect(operation).toHaveBeenCalledTimes(1) + await lease.closed + expect(jest.getTimerCount()).toBe(0) +}) + +test('cleanup retries an identical cancellation once and retains a second transport failure', async () => { + const { transport } = remoteReaderFixture([]) + const failure = new SnapshotArchiveTransportFailure('synthetic repeated loss') + const cancel = jest.spyOn(transport, 'cancelRequest').mockRejectedValue(failure) + const lease = new RemoteSnapshotLease(transport, {}) + lease.own(request()) + await expect(lease.close()).rejects.toBe(failure) + await expect(lease.closed).rejects.toBe(failure) + expect(cancel).toHaveBeenCalledTimes(2) + expect(cancel.mock.calls[0][0]).toBe(cancel.mock.calls[1][0]) +}) + +test.each(['timer', 'abort', 'deadline-check'] as const)( + '%s automatic closure retains a failed cancellation receipt for its observer', + async reason => { + jest.useFakeTimers() + const { transport } = remoteReaderFixture([]) + const controller = new AbortController() + const failure = new Error('synthetic cancellation receipt failure') + const cancel = jest.spyOn(transport, 'cancelRequest').mockRejectedValue(failure) + const lease = new RemoteSnapshotLease(transport, { lifetimeMs: 10, signal: controller.signal }) + lease.own(request()) + if (reason === 'timer') await jest.advanceTimersByTimeAsync(10) + else if (reason === 'abort') controller.abort() + else { + jest.setSystemTime(Date.now() + 10) + expect(() => lease.assertOpen()).toThrow('expired') + } + await expect(lease.closed).rejects.toBe(failure) + await expect(lease.close()).rejects.toBe(failure) + expect(cancel).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + } +) + +test.each([1, 3600000])('inclusive lifetime %p retains its exact deadline', async lifetimeMs => { + jest.useFakeTimers({ now: 1790812800000 }) + const { transport } = remoteReaderFixture([]) + const lease = new RemoteSnapshotLease(transport, { lifetimeMs }) + expect(lease.lifetimeMs).toBe(lifetimeMs) + expect(lease.expiresAt).toBe(1790812800000 + lifetimeMs) + expect(lease.isOpen).toBe(true) + await lease.close() + expect(jest.getTimerCount()).toBe(0) +}) + +test('a nonzero monotonic origin and stalled timers cannot extend the fixed server clock or deadline', async () => { + jest.useFakeTimers({ now: 1790812800000 }) + const monotonic = jest.spyOn(performance, 'now').mockReturnValue(4000) + const { transport } = remoteReaderFixture([]) + const lease = new RemoteSnapshotLease(transport, { lifetimeMs: 1000 }) + monotonic.mockReturnValue(4125) + lease.bindServerTime(100000) + expect(lease.now()).toBe(100125) + monotonic.mockReturnValue(4999) + expect(lease.now()).toBe(100999) + jest.setSystemTime(1790812800000 - 3600000) + monotonic.mockReturnValue(5000) + expect(() => lease.assertOpen()).toThrow('expired') + await lease.closed + expect(jest.getTimerCount()).toBe(0) +}) + +test('poll delay holds the operation until its exact duration without issuing remote work', async () => { + jest.useFakeTimers() + const { transport, rpc } = remoteReaderFixture([]) + const lease = new RemoteSnapshotLease(transport, { lifetimeMs: 1000 }) + let finished = false + const waiting = lease.wait(100).then(() => { + finished = true + }) + await jest.advanceTimersByTimeAsync(99) + expect(finished).toBe(false) + await jest.advanceTimersByTimeAsync(1) + await waiting + expect(finished).toBe(true) + expect(rpc).not.toHaveBeenCalled() + await lease.close() + expect(jest.getTimerCount()).toBe(0) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts new file mode 100644 index 000000000..f35ef9368 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts @@ -0,0 +1,165 @@ +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { SnapshotCancelledError } from '../SnapshotCancelledError' +import { isSnapshotArchiveTransportFailure, retrySnapshotArchiveOperation } from './SnapshotArchiveTransportFailure' +import type { WalletReadSnapshotOptions } from '../WalletReadSnapshot' +import { parseSnapshotArchiveReaderRequest, type SnapshotArchiveReaderRequest } from './SnapshotArchiveReaderRequest' +import type { SnapshotArchiveTransport } from './SnapshotArchiveTransport' + +/** Owns one opening/read operation and one expiry timer until remote cleanup settles. */ +export class RemoteSnapshotLease { + readonly lifetimeMs: number + readonly expiresAt: number + readonly closed: Promise + private readonly startedAt = performance.now() + private readonly controller = new AbortController() + private readonly signal: AbortSignal | undefined + private timer: ReturnType | undefined + private pending: Promise | undefined + private request: Readonly | undefined + private serverTime: number | undefined + private reason: WERR_INVALID_OPERATION | undefined + private closing: Promise | undefined + private resolveClosed!: () => void + private rejectClosed!: (error: unknown) => void + + constructor( + private readonly transport: SnapshotArchiveTransport, + options: WalletReadSnapshotOptions + ) { + this.lifetimeMs = options.lifetimeMs ?? 300000 + if (!Number.isSafeInteger(this.lifetimeMs) || this.lifetimeMs < 1 || this.lifetimeMs > 3600000) + throw new WERR_INVALID_PARAMETER('lifetimeMs', 'an integer from 1 to 3600000') + this.expiresAt = Date.now() + this.lifetimeMs + this.signal = options.signal + this.closed = new Promise((resolve, reject) => { + this.resolveClosed = resolve + this.rejectClosed = reject + }) + void this.closed.catch(() => undefined) + this.signal?.addEventListener('abort', this.abort, { once: true }) + if (this.signal?.aborted === true) this.abort() + else + this.timer = setTimeout(() => { + void this.close(new SnapshotResourceLimitError('Remote snapshot expired')).catch(() => undefined) + }, this.lifetimeMs) + } + + private readonly abort = (): void => { + void this.close(new SnapshotCancelledError('Remote snapshot was cancelled')).catch(() => undefined) + } + + get isOpen(): boolean { + try { + this.assertOpen() + return true + } catch { + return false + } + } + + assertOpen(): void { + if (Date.now() >= this.expiresAt || performance.now() - this.startedAt >= this.lifetimeMs) + void this.close(new SnapshotResourceLimitError('Remote snapshot expired')).catch(() => undefined) + if (this.reason !== undefined) throw this.reason + } + + bindServerTime(serverTime: number): void { + this.assertOpen() + if (this.serverTime !== undefined) throw new WERR_INVALID_OPERATION('Remote snapshot clock is already bound') + this.serverTime = serverTime + } + + /** Includes the entire offer RTT conservatively; retries never renew this basis. */ + now(): number { + this.assertOpen() + if (this.serverTime === undefined) throw new WERR_INVALID_OPERATION('Remote snapshot clock is not bound') + return Math.ceil(this.serverTime + performance.now() - this.startedAt) + } + + own(request: SnapshotArchiveReaderRequest): void { + this.assertOpen() + if (this.request !== undefined) throw new WERR_INVALID_OPERATION('Remote snapshot request is already owned') + this.request = parseSnapshotArchiveReaderRequest(request) + } + + async run(operation: (signal: AbortSignal) => Promise): Promise { + this.assertOpen() + if (this.pending !== undefined) + throw new WERR_INVALID_OPERATION('Remote snapshot already has an operation in flight') + const pending = Promise.resolve().then(async () => { + this.assertOpen() + return await operation(this.controller.signal) + }) + // Own settlement immediately, separately from the result returned to the + // caller. Cleanup drains failures without replacing their public identity. + this.pending = pending.then( + () => undefined, + () => undefined + ) + try { + const value = await pending + this.assertOpen() + return value + } catch (error) { + // Only a local transport cancellation may be explained by our abort. + // Authentication, framing and cleanup failures retain their own identity. + if ( + this.reason !== undefined && + (error instanceof SnapshotCancelledError || isSnapshotArchiveTransportFailure(error)) + ) + throw this.reason + throw error + } finally { + this.pending = undefined + } + } + + /** Keep one operation slot and the fixed lease across a single connection-loss retry. */ + async runIdempotent(operation: (signal: AbortSignal) => Promise): Promise { + return await this.run(async signal => + retrySnapshotArchiveOperation(async () => { + this.assertOpen() + return await operation(signal) + }) + ) + } + + async wait(milliseconds: number): Promise { + await this.run( + async signal => + await new Promise((resolve, reject) => { + const finish = (): void => { + signal.removeEventListener('abort', abort) + resolve() + } + const timer = setTimeout(finish, milliseconds) + const abort = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', abort) + // Only close() aborts this private signal, after setting its reason. + reject(this.reason) + } + // run() asserted openness immediately before this synchronous setup. + signal.addEventListener('abort', abort, { once: true }) + }) + ) + } + + close(reason = new WERR_INVALID_OPERATION('Remote snapshot is closed')): Promise { + if (this.closing !== undefined) return this.closing + this.reason = reason + if (this.timer !== undefined) clearTimeout(this.timer) + this.signal?.removeEventListener('abort', this.abort) + const pending = this.pending + // Fence first, then drain the network operation before releasing its shared receipt. + this.closing = Promise.resolve().then(async () => { + await pending + const request = this.request + if (request !== undefined) await retrySnapshotArchiveOperation(async () => this.transport.cancelRequest(request)) + }) + void this.closing.then(this.resolveClosed, this.rejectClosed) + this.controller.abort() + return this.closing + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotOpening.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotOpening.test.ts new file mode 100644 index 000000000..ccfa3bf84 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotOpening.test.ts @@ -0,0 +1,211 @@ +import { remoteReaderFixture } from '../../../../test/utils/remoteSnapshotReaderFixtures' +import { openRemoteSnapshot, RemoteSnapshotOpeningCleanupError } from './openRemoteSnapshot' +import { SnapshotArchiveTransportFailure } from './SnapshotArchiveTransportFailure' +import { SnapshotArchiveTransport } from './SnapshotArchiveTransport' + +afterEach(() => jest.restoreAllMocks()) + +test('an old server is declined before any request or timer is retained', async () => { + jest.useFakeTimers() + try { + const rpc = jest.fn() + const transport = new SnapshotArchiveTransport(rpc, '02' + '11'.repeat(32), 'source', 'test') + expect(await openRemoteSnapshot(transport)).toBeUndefined() + expect(rpc).not.toHaveBeenCalled() + expect(jest.getTimerCount()).toBe(0) + } finally { + jest.useRealTimers() + } +}) + +test('a refused server offer declines before any capture request exists', async () => { + const { transport, rpc } = remoteReaderFixture([]) + jest.spyOn(transport, 'readerOffer').mockResolvedValue({ version: 1, outcome: 'resource-limited' }) + expect(await openRemoteSnapshot(transport)).toBeUndefined() + expect(rpc).not.toHaveBeenCalled() +}) + +test.each(['before', 'after'])( + 'one same-request retry recovers an admission response lost %s the server accepted it', + async when => { + const { transport, rpc } = remoteReaderFixture([]) + const implementation = transport.admit.bind(transport) + const admitted = jest.spyOn(transport, 'admit') + admitted.mockImplementationOnce(async (request, signal) => { + if (when === 'after') await implementation(request, signal) + throw new SnapshotArchiveTransportFailure(new Error('synthetic lost response')) + }) + const view = (await openRemoteSnapshot(transport))! + try { + expect(admitted).toHaveBeenCalledTimes(2) + expect(admitted.mock.calls[1][0]).toEqual(admitted.mock.calls[0][0]) + expect(rpc.mock.calls.filter(([method]) => method === 'getSnapshotArchiveReaderOffer')).toHaveLength(1) + expect(rpc.mock.calls.filter(([method]) => method === 'getSnapshotArchiveStatus')).toHaveLength(0) + expect(view.isOpen).toBe(true) + } finally { + await view.close() + } + } +) + +test('an admission refusal closes its retained offer before permitting fallback', async () => { + const { transport, rpc } = remoteReaderFixture([]) + const admitted = jest.spyOn(transport, 'admit').mockImplementation(async request => ({ + version: 1, + outcome: 'resource-limited', + requestId: request.requestId, + expiresAt: request.notAfter + })) + expect(await openRemoteSnapshot(transport)).toBeUndefined() + const request = admitted.mock.calls[0][0] + expect(rpc.mock.calls.map(([method]) => method)).toEqual([ + 'getSnapshotArchiveReaderOffer', + 'cancelSnapshotArchiveRequest' + ]) + expect(rpc.mock.calls[1][1]).toEqual([{ version: 1, identityKey: expect.any(String), request }]) +}) + +test('unknown admission errors never trigger retries, status recovery or compatibility fallback', async () => { + const { transport, rpc } = remoteReaderFixture([]) + const failure = new Error('synthetic signature or framing validation failure') + const admitted = jest.spyOn(transport, 'admit').mockRejectedValue(failure) + await expect(openRemoteSnapshot(transport)).rejects.toBe(failure) + expect(admitted).toHaveBeenCalledTimes(1) + expect(rpc.mock.calls.map(([method]) => method)).toEqual([ + 'getSnapshotArchiveReaderOffer', + 'cancelSnapshotArchiveRequest' + ]) +}) + +test('a second native-fetch failure is bounded and still cancels the identical request', async () => { + const { transport, rpc } = remoteReaderFixture([]) + const failure = new SnapshotArchiveTransportFailure(new Error('synthetic repeated connection loss')) + const admitted = jest.spyOn(transport, 'admit').mockRejectedValue(failure) + await expect(openRemoteSnapshot(transport)).rejects.toBe(failure) + expect(admitted).toHaveBeenCalledTimes(2) + expect(admitted.mock.calls[0][0]).toEqual(admitted.mock.calls[1][0]) + expect(rpc.mock.calls.map(([method]) => method)).toEqual([ + 'getSnapshotArchiveReaderOffer', + 'cancelSnapshotArchiveRequest' + ]) +}) + +test('opening and cancellation failures both remain observable without requiring AggregateError', async () => { + const { transport } = remoteReaderFixture([]) + const original = new Error('synthetic malformed archive') + const cleanup = new Error('synthetic failed cancellation acknowledgement') + jest.spyOn(transport, 'directory').mockRejectedValue(original) + jest.spyOn(transport, 'cancelRequest').mockRejectedValue(cleanup) + const error = await openRemoteSnapshot(transport).catch(error => error) + expect(error).toBeInstanceOf(RemoteSnapshotOpeningCleanupError) + expect(error.cause).toBe(original) + expect(error.cleanupError).toBe(cleanup) +}) + +test('status recovery uses the admitted tuple without requesting another offer or capture', async () => { + const { transport, rpc } = remoteReaderFixture([]) + const admit = transport.admit.bind(transport) + jest.spyOn(transport, 'admit').mockImplementation(async (...args) => { + const admission = await admit(...args) + if (admission.outcome !== 'accepted') throw new Error('Expected fixture admission') + return { + ...admission, + receipt: { ...admission.receipt, state: 'building', archiveId: undefined, digest: undefined } + } + }) + const status = jest.spyOn(transport, 'readerStatus') + status.mockRejectedValueOnce(new SnapshotArchiveTransportFailure('synthetic status connection loss')) + const view = (await openRemoteSnapshot(transport))! + try { + expect(status).toHaveBeenCalledTimes(2) + expect(status.mock.calls[0]).toEqual(status.mock.calls[1]) + expect(rpc.mock.calls.filter(([method]) => method === 'getSnapshotArchiveReaderOffer')).toHaveLength(1) + expect(rpc.mock.calls.filter(([method]) => method === 'admitSnapshotArchive')).toHaveLength(1) + } finally { + await view.close() + } +}) + +test.each(['resource-limited', 'failed', 'closed', 'expired'] as const)( + 'a durable %s capture receipt is preserved until exact cancellation completes', + async state => { + const { transport, rpc } = remoteReaderFixture([]) + const implementation = transport.admit.bind(transport) + jest.spyOn(transport, 'admit').mockImplementation(async (...args) => { + const admitted = await implementation(...args) + if (admitted.outcome !== 'accepted') throw new Error('Expected fixture admission') + return { + ...admitted, + receipt: { + version: 1, + requestId: admitted.receipt.requestId, + expiresAt: admitted.receipt.expiresAt, + state + } + } + }) + const pending = openRemoteSnapshot(transport) + if (state === 'resource-limited') await expect(pending).resolves.toBeUndefined() + else await expect(pending).rejects.toThrow(`Snapshot archive capture is ${state}`) + expect(rpc.mock.calls.map(([method]) => method)).toEqual([ + 'getSnapshotArchiveReaderOffer', + 'admitSnapshotArchive', + 'cancelSnapshotArchiveRequest' + ]) + } +) + +test('directory verification cannot expose a view at the conservatively rounded server expiry', async () => { + jest.useFakeTimers() + const monotonic = jest.spyOn(performance, 'now').mockReturnValue(0) + const { transport, rpc } = remoteReaderFixture([]) + const directory = transport.directory.bind(transport) + jest.spyOn(transport, 'directory').mockImplementation(async (...args) => { + const result = await directory(...args) + monotonic.mockReturnValue(999.1) + return result + }) + try { + await expect(openRemoteSnapshot(transport, { lifetimeMs: 1000 })).rejects.toThrow('Remote snapshot expired') + expect(rpc.mock.calls.filter(([method]) => method === 'cancelSnapshotArchiveRequest')).toHaveLength(1) + expect(jest.getTimerCount()).toBe(0) + } finally { + jest.useRealTimers() + } +}) + +test('building status uses bounded exponential polling without renewing or readmitting its request', async () => { + jest.useFakeTimers({ now: 1790812800000 }) + const controller = new AbortController() + const { transport, rpc } = remoteReaderFixture([]) + const originalAdmit = transport.admit.bind(transport) + const originalStatus = transport.readerStatus.bind(transport) + const times: number[] = [] + jest.spyOn(transport, 'admit').mockImplementation(async (...args) => { + const admitted = await originalAdmit(...args) + if (admitted.outcome !== 'accepted') throw new Error('Expected accepted fixture') + return { ...admitted, receipt: { ...admitted.receipt, state: 'building' } } + }) + const status = jest.spyOn(transport, 'readerStatus').mockImplementation(async (...args) => { + times.push(Date.now() - 1790812800000) + const receipt = await originalStatus(...args) + return times.length < 6 ? { ...receipt, state: 'building' } : receipt + }) + const pending = openRemoteSnapshot(transport, { signal: controller.signal }) + void pending.catch(() => undefined) + try { + await jest.advanceTimersByTimeAsync(3500) + expect(times).toEqual([100, 300, 700, 1500, 2500, 3500]) + const view = await pending + expect(view!.isOpen).toBe(true) + expect(view!.expiresAt).toBe(1790813100000) + expect(status.mock.calls.every(call => call[0] === status.mock.calls[0][0])).toBe(true) + expect(rpc.mock.calls.filter(([method]) => method === 'getSnapshotArchiveReaderOffer')).toHaveLength(1) + expect(rpc.mock.calls.filter(([method]) => method === 'admitSnapshotArchive')).toHaveLength(1) + } finally { + controller.abort() + const view = await pending.catch(() => undefined) + await view?.close() + jest.useRealTimers() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotPageReader.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotPageReader.ts new file mode 100644 index 000000000..76255e992 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotPageReader.ts @@ -0,0 +1,221 @@ +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import type { + PackedSnapshotRow, + WalletReadSnapshot, + WalletSnapshotCursor, + WalletSnapshotPage, + WalletSnapshotPageLimits, + WalletSnapshotTable, + WalletSnapshotTables +} from '../WalletReadSnapshot' +import type { VerifiedSnapshotArchiveDirectory } from './SnapshotArchiveDirectory' +import type { SnapshotArchiveTransport } from './SnapshotArchiveTransport' +import type { RemoteSnapshotLease } from './RemoteSnapshotLease' +import { + decodeRemoteSnapshotFrame, + detachRemoteSnapshotRow, + remoteSnapshotKeys, + type RemoteSnapshotFrame, + type RemoteSnapshotRow +} from './RemoteSnapshotRows' + +interface Position { + sequence: number + rowOffset: number +} +interface DetachedCursor { + cursor: WalletSnapshotCursor + position: Position +} + +function invalidCursor(): never { + throw new WERR_INVALID_PARAMETER('cursor', 'a verified position in this snapshot archive and table') +} +function data(input: unknown, names: string[]): Record { + if ( + input === null || + typeof input !== 'object' || + Array.isArray(input) || + Reflect.ownKeys(input).length !== names.length + ) + invalidCursor() + return Object.fromEntries( + names.map(name => { + const property = Object.getOwnPropertyDescriptor(input, name) + if (property === undefined || !('value' in property) || !property.enumerable) invalidCursor() + return [name, property.value] + }) + ) +} +function detachCursor( + input: WalletSnapshotCursor | undefined, + table: WalletSnapshotTable, + directory: VerifiedSnapshotArchiveDirectory +): DetachedCursor | undefined { + if (input === undefined) return undefined + const cursor = data(input, ['version', 'snapshotId', 'table', 'after', 'archivePosition']) + const position = data(cursor.archivePosition, ['version', 'archiveId', 'sequence', 'rowOffset']) + const keys = remoteSnapshotKeys(table) + const range = directory.tables[table] + if ( + cursor.version !== 1 || + cursor.snapshotId !== directory.manifest.binding.snapshotId || + cursor.table !== table || + !Array.isArray(cursor.after) || + cursor.after.length !== keys.length || + position.version !== 1 || + position.archiveId !== directory.manifest.archiveId || + !Number.isSafeInteger(position.sequence) || + (position.sequence as number) < range.first || + (position.sequence as number) >= range.first + range.pages || + !Number.isSafeInteger(position.rowOffset) || + (position.rowOffset as number) < 1 + ) + invalidCursor() + const sequence = position.sequence as number + const rowOffset = position.rowOffset as number + if (rowOffset > directory.receipts[sequence].rows) invalidCursor() + const after = keys.map((key, index) => { + const property = Object.getOwnPropertyDescriptor(cursor.after, index) + if (property === undefined || !('value' in property)) invalidCursor() + const value: unknown = property.value + if ( + key === 'fieldName' + ? typeof value !== 'string' || value.length > 200 || Array.from(value).length > 100 + : !Number.isSafeInteger(value) || (value as number) < 1 + ) + invalidCursor() + return value as number | string + }) + return { + position: { sequence, rowOffset }, + cursor: { + version: 1, + snapshotId: directory.manifest.binding.snapshotId, + table, + after, + archivePosition: { version: 1, archiveId: directory.manifest.archiveId, sequence, rowOffset } + } + } +} + +function limit(value: number | undefined, fallback: number, ceiling: number, name: string): number { + const result = value ?? fallback + if (!Number.isSafeInteger(result) || result < 1 || result > ceiling) + throw new WERR_INVALID_PARAMETER(name, `an integer from 1 to ${ceiling}`) + return result +} + +/** One private decoded frame, plus returned rows charged to the caller's allocation budget. */ +export function createRemoteSnapshotPageReader( + transport: SnapshotArchiveTransport, + directory: VerifiedSnapshotArchiveDirectory, + lease: RemoteSnapshotLease +): WalletReadSnapshot['readPage'] { + let cached: { sequence: number; frame: RemoteSnapshotFrame } | undefined + let busy = false + const discard = (): void => { + cached = undefined + } + void lease.closed.then(discard, discard) + const assertActive = (): void => { + lease.assertOpen() + if (lease.now() >= directory.manifest.expiresAt) { + const error = new SnapshotResourceLimitError('Remote snapshot expired') + void lease.close(error).catch(() => undefined) + throw error + } + } + const frameAt = async (sequence: number): Promise => { + assertActive() + if (cached?.sequence === sequence) return cached.frame + discard() + try { + const bytes = await lease.runIdempotent(signal => transport.page(directory, sequence, signal)) + assertActive() + const frame = decodeRemoteSnapshotFrame( + bytes, + directory.receipts[sequence], + directory.manifest.binding.user.userId + ) + cached = { sequence, frame } + return frame + } catch (error) { + void lease.close(new WERR_INVALID_OPERATION('Remote snapshot read failed')).catch(() => undefined) + throw error + } + } + const cursorAfter = ( + table: WalletSnapshotTable, + row: RemoteSnapshotRow, + sequence: number, + rowOffset: number + ): WalletSnapshotCursor => ({ + version: 1, + snapshotId: directory.manifest.binding.snapshotId, + table, + after: remoteSnapshotKeys(table).map(key => row[key] as number | string), + archivePosition: { version: 1, archiveId: directory.manifest.archiveId, sequence, rowOffset } + }) + const verifiedPosition = async ( + table: WalletSnapshotTable, + cursor: DetachedCursor | undefined + ): Promise => { + if (cursor === undefined) return { sequence: directory.tables[table].first, rowOffset: 0 } + const frame = await frameAt(cursor.position.sequence) + const row = frame.rows[cursor.position.rowOffset - 1] + if (cursor.cursor.after.some((value, index) => value !== row[remoteSnapshotKeys(table)[index]])) invalidCursor() + return { ...cursor.position } + } + return async ( + table: T, + cursor?: WalletSnapshotCursor, + limits: WalletSnapshotPageLimits = {} + ): Promise> => { + assertActive() + if (busy) throw new WERR_INVALID_OPERATION('Remote snapshot already has a read in flight') + remoteSnapshotKeys(table) + const initialCursor = detachCursor(cursor, table, directory) + const maxRows = limit(limits.maxRows, 128, 1000, 'maxRows') + const maxBytes = limit(limits.maxBytes, 262144, 16777216, 'maxBytes') + busy = true + try { + const position = await verifiedPosition(table, initialCursor) + const rows: Array> = [] + let payloadBytes = 0 + let nextCursor = initialCursor?.cursor + let done = false + let full = false + while (!done && !full) { + const frame = await frameAt(position.sequence) + const receipt = directory.receipts[position.sequence] + while (position.rowOffset < frame.rows.length && rows.length < maxRows) { + const charge = frame.charges[position.rowOffset] + if (payloadBytes + charge > maxBytes) { + if (rows.length === 0) + throw new SnapshotResourceLimitError('Snapshot row exceeds maxBytes; large-value streaming is required') + full = true + break + } + const row = frame.rows[position.rowOffset] + rows.push(detachRemoteSnapshotRow(row) as PackedSnapshotRow) + payloadBytes += charge + position.rowOffset++ + nextCursor = cursorAfter(table, row, position.sequence, position.rowOffset) + } + const consumed = position.rowOffset === frame.rows.length + done = consumed && receipt.done + full ||= rows.length === maxRows + if (consumed && !done) { + position.sequence++ + position.rowOffset = 0 + } + } + assertActive() + return { rows, payloadBytes, cursor: nextCursor, done } + } finally { + busy = false + } + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts new file mode 100644 index 000000000..729ffd02f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts @@ -0,0 +1,68 @@ +import fc from 'fast-check' +import { openRemoteSnapshot } from './openRemoteSnapshot' +import { label, remoteReaderFixture } from '../../../../test/utils/remoteSnapshotReaderFixtures' +import type { WalletSnapshotCursor } from '../WalletReadSnapshot' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +test('generated frame/page boundaries preserve exact rows, final keys, allocation charges and immutable retries', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.string({ maxLength: 40 }), { minLength: 1, maxLength: 64 }), + fc.integer({ min: 1, max: 17 }), + fc.integer({ min: 1, max: 13 }), + async (names, frameRows, maxRows) => { + const expected = names.map((name, index) => label(index + 1, name)) + const fixture = remoteReaderFixture(expected, 'txLabels', frameRows) + const view = (await openRemoteSnapshot(fixture.transport))! + try { + let cursor: WalletSnapshotCursor | undefined + let consumed = 0 + let done = false + while (!done) { + const page = await view.readPage('txLabels', cursor, { maxRows, maxBytes: 65536 }) + expect(page.rows).toEqual(expected.slice(consumed, consumed + maxRows)) + expect(page.payloadBytes).toBe(page.rows.reduce((sum, row) => sum + 384 + row.label.length * 2, 0)) + expect(await view.readPage('txLabels', cursor, { maxRows, maxBytes: 65536 })).toEqual(page) + consumed += page.rows.length + expect(page.cursor!.after).toEqual([consumed]) + expect(page.cursor!.archivePosition).toEqual({ + version: 1, + archiveId: fixture.directory.archiveId, + sequence: 8 + Math.floor((consumed - 1) / frameRows), + rowOffset: ((consumed - 1) % frameRows) + 1 + }) + cursor = page.cursor + done = page.done + expect(done).toBe(consumed === expected.length) + } + expect(consumed).toBe(expected.length) + expect((await view.readPage('txLabels', cursor)).rows).toEqual([]) + const sequenceRequests = fixture.rpc.mock.calls + .filter(([method]) => method === 'readSnapshotArchivePage') + .map(([, params]) => (params[0] as { sequence: number }).sequence) + expect( + sequenceRequests.every(sequence => sequence >= 8 && sequence < 8 + Math.ceil(expected.length / frameRows)) + ).toBe(true) + await expect( + view.readPage('txLabels', { + ...cursor!, + archivePosition: { ...cursor!.archivePosition!, archiveId: 'c'.repeat(64) } + }) + ).rejects.toThrow() + } finally { + await view.close() + } + expect(fixture.rpc.mock.calls.filter(([method]) => method === 'cancelSnapshotArchiveRequest')).toHaveLength(1) + } + ) + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.test.ts new file mode 100644 index 000000000..e855cbbd7 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.test.ts @@ -0,0 +1,334 @@ +import { openRemoteSnapshot } from './openRemoteSnapshot' +import { label, remoteReaderFixture } from '../../../../test/utils/remoteSnapshotReaderFixtures' +import { snapshotArchiveTables } from './SnapshotArchive' +import type { WalletSnapshotCursor } from '../WalletReadSnapshot' + +afterEach(() => jest.restoreAllMocks()) + +test('verified table positioning slices rows and bytes without downloading earlier tables', async () => { + const rows = Array.from({ length: 35 }, (_, index) => label(index + 1)) + const fixture = remoteReaderFixture(rows, 'txLabels', 17) + const view = (await openRemoteSnapshot(fixture.transport))! + try { + const first = await view.readPage('txLabels', undefined, { maxRows: 5 }) + expect(first.rows).toEqual(rows.slice(0, 5)) + expect(first.done).toBe(false) + expect(first.cursor).toEqual({ + version: 1, + snapshotId: view.snapshotId, + table: 'txLabels', + after: [5], + archivePosition: { version: 1, archiveId: fixture.directory.archiveId, sequence: 8, rowOffset: 5 } + }) + expect(first.payloadBytes).toBe(5 * (6 * 64 + 'label-1'.length * 2)) + expect(await view.readPage('txLabels', undefined, { maxRows: 5 })).toEqual(first) + const collected = [...first.rows] + let page = first + while (!page.done) { + page = await view.readPage('txLabels', page.cursor, { maxRows: 5 }) + collected.push(...page.rows) + } + expect(collected).toEqual(rows) + const requested = fixture.rpc.mock.calls + .filter(([method]) => method === 'readSnapshotArchivePage') + .map(([, params]) => (params[0] as { sequence: number }).sequence) + expect(requested).toEqual([8, 9, 10]) + expect((await view.readPage('txLabels', page.cursor)).rows).toEqual([]) + expect((await view.readPage('txLabels', page.cursor)).done).toBe(true) + expect(view.sourceStorage.storageIdentityKey).toBe('original-storage') + expect(view.user.activeStorage).toBe('historical-primary') + expect(view.isOpen).toBe(true) + } finally { + await view.close() + } + expect(view.isOpen).toBe(false) + await expect(view.closed).resolves.toBeUndefined() + expect(fixture.rpc.mock.calls.filter(([method]) => method === 'cancelSnapshotArchiveRequest')).toHaveLength(1) +}) + +test('every empty table still authenticates and decodes its terminal frame', async () => { + const fixture = remoteReaderFixture([]) + const view = (await openRemoteSnapshot(fixture.transport))! + try { + for (const table of snapshotArchiveTables) + expect(await view.readPage(table)).toEqual({ rows: [], payloadBytes: 0, cursor: undefined, done: true }) + expect(fixture.rpc.mock.calls.filter(([method]) => method === 'readSnapshotArchivePage')).toHaveLength(13) + } finally { + await view.close() + } +}) + +test('returned metadata, dates, rows and cursor objects cannot mutate the private archive cache', async () => { + const fixture = remoteReaderFixture([label(1), label(2)]) + const view = (await openRemoteSnapshot(fixture.transport))! + try { + const expected = await view.readPage('txLabels', undefined, { maxRows: 1 }) + const page = await view.readPage('txLabels', undefined, { maxRows: 1 }) + view.sourceStorage.created_at.setTime(0) + view.user.updated_at.setTime(0) + page.rows[0].created_at.setTime(0) + page.rows[0].label = 'changed by caller' + ;(page.cursor!.after as number[])[0] = 999 + ;(page.cursor!.archivePosition as { rowOffset: number }).rowOffset = 2 + expect(await view.readPage('txLabels', undefined, { maxRows: 1 })).toEqual(expected) + expect(view.sourceStorage.created_at.getTime()).not.toBe(0) + expect(view.user.updated_at.getTime()).not.toBe(0) + expect((await view.readPage('txLabels', expected.cursor, { maxRows: 1 })).rows).toEqual([label(2)]) + } finally { + await view.close() + } +}) + +test('cursor positions must match the actual last key in the authenticated frame', async () => { + const fixture = remoteReaderFixture([label(1), label(2), label(3)], 'txLabels', 2) + const view = (await openRemoteSnapshot(fixture.transport))! + try { + const { cursor } = await view.readPage('txLabels', undefined, { maxRows: 1 }) + const changes = [ + { version: 2 }, + { snapshotId: 'c'.repeat(64) }, + { table: 'outputs' }, + { after: [2] }, + { archivePosition: { ...cursor!.archivePosition, archiveId: 'c'.repeat(64) } }, + { archivePosition: { ...cursor!.archivePosition, sequence: 7 } }, + { archivePosition: { ...cursor!.archivePosition, sequence: 10 } }, + { archivePosition: { ...cursor!.archivePosition, rowOffset: 0 } }, + { archivePosition: { ...cursor!.archivePosition, rowOffset: 3 } }, + { archivePosition: undefined } + ] + for (const change of changes) + await expect(view.readPage('txLabels', { ...cursor!, ...change } as WalletSnapshotCursor)).rejects.toThrow() + expect((await view.readPage('txLabels', cursor)).rows).toEqual([label(2), label(3)]) + } finally { + await view.close() + } +}) + +test('row budgets are inclusive, preserve continuation and never omit an oversized row', async () => { + const fixture = remoteReaderFixture([label(1), label(2)]) + const view = (await openRemoteSnapshot(fixture.transport))! + const charge = 6 * 64 + 'label-1'.length * 2 + try { + await expect(view.readPage('txLabels', undefined, { maxBytes: charge - 1 })).rejects.toThrow('exceeds maxBytes') + const first = await view.readPage('txLabels', undefined, { maxBytes: charge }) + expect(first.rows).toEqual([label(1)]) + expect(first.done).toBe(false) + const last = await view.readPage('txLabels', first.cursor, { maxBytes: charge }) + expect(last.rows).toEqual([label(2)]) + expect(last.done).toBe(true) + expect(last.payloadBytes).toBe(charge) + } finally { + await view.close() + } +}) + +test('certificate-field continuation preserves source SQL collation instead of applying JavaScript order', async () => { + const rows = ['a', 'Z', 'é', '😀'].map(fieldName => ({ + created_at: new Date('2026-01-01T00:00:00.000Z'), + updated_at: new Date('2026-01-01T00:00:00.000Z'), + userId: 7, + certificateId: 1, + fieldName, + fieldValue: fieldName, + masterKey: 'key' + })) + const fixture = remoteReaderFixture(rows, 'certificateFields', 2) + const view = (await openRemoteSnapshot(fixture.transport))! + try { + let cursor: WalletSnapshotCursor | undefined + for (const row of rows) { + const page = await view.readPage('certificateFields', cursor, { maxRows: 1 }) + expect(page.rows).toEqual([row]) + expect(page.cursor!.after).toEqual([row.fieldName, 1]) + cursor = page.cursor + } + } finally { + await view.close() + } +}) + +test('cursor descriptors and numeric positions reject before invoking user accessors', async () => { + const { transport } = remoteReaderFixture([label(1), label(2)]) + const view = (await openRemoteSnapshot(transport))! + try { + const { cursor } = await view.readPage('txLabels', undefined, { maxRows: 1 }) + const getter = jest.fn(() => { + throw new Error('must not execute') + }) + const afterGetter = Object.defineProperty({ ...cursor! }, 'after', { get: getter }) + const hiddenAfter = Object.defineProperty({ ...cursor! }, 'after', { value: [1], enumerable: false }) + const missingAfter = { ...cursor!, renamedAfter: [1] } as Record + delete missingAfter.after + const arrayGetter = Object.defineProperty([1], '0', { get: getter }) + const sparse: unknown[] = [] + sparse.length = 1 + const invalid: unknown[] = [ + afterGetter, + hiddenAfter, + missingAfter, + { ...cursor, after: arrayGetter }, + { ...cursor, after: sparse }, + { ...cursor, after: null }, + { ...cursor, after: [] }, + { ...cursor, after: [1, 2] }, + { ...cursor, archivePosition: null }, + { ...cursor, archivePosition: [] }, + { ...cursor, archivePosition: { ...cursor!.archivePosition, version: 2 } } + ] + for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + invalid.push({ ...cursor, after: [value] }) + invalid.push({ ...cursor, archivePosition: { ...cursor!.archivePosition, rowOffset: value } }) + } + for (const value of [-1, 1.5, NaN, Infinity]) + invalid.push({ ...cursor, archivePosition: { ...cursor!.archivePosition, sequence: value } }) + for (const value of invalid) + await expect(view.readPage('txLabels', value as WalletSnapshotCursor)).rejects.toThrow('verified position') + expect(getter).not.toHaveBeenCalled() + expect((await view.readPage('txLabels', cursor)).rows).toEqual([label(2)]) + } finally { + await view.close() + } +}) + +test('page limits reject invalid numbers without consuming remote work', async () => { + const { transport, rpc } = remoteReaderFixture([label(1)]) + const view = (await openRemoteSnapshot(transport))! + try { + for (const field of ['maxRows', 'maxBytes'] as const) { + const ceiling = field === 'maxRows' ? 1000 : 16777216 + for (const value of [0, -1, 1.5, NaN, Infinity, ceiling + 1]) + await expect(view.readPage('txLabels', undefined, { [field]: value })).rejects.toThrow(field) + } + expect(rpc.mock.calls.filter(([method]) => method === 'readSnapshotArchivePage')).toHaveLength(0) + expect((await view.readPage('txLabels', undefined, { maxRows: 1000, maxBytes: 16777216 })).rows).toEqual([label(1)]) + } finally { + await view.close() + } +}) + +test('a delayed frame owns the only read slot through completion', async () => { + const { transport } = remoteReaderFixture([label(1)]) + const view = (await openRemoteSnapshot(transport))! + const original = transport.page.bind(transport) + let enter!: () => void + let release!: () => void + const entered = new Promise(resolve => { + enter = resolve + }) + const gate = new Promise(resolve => { + release = resolve + }) + jest.spyOn(transport, 'page').mockImplementation(async (...args) => { + enter() + await gate + return original(...args) + }) + const first = view.readPage('txLabels') + void first.catch(() => undefined) + try { + await entered + await expect(view.readPage('txLabels')).rejects.toThrow('already has a read in flight') + release() + expect((await first).rows).toEqual([label(1)]) + expect((await view.readPage('txLabels')).rows).toEqual([label(1)]) + } finally { + release() + await first.catch(() => undefined) + await view.close() + } +}) + +test('a read stops at conservative server expiry before the local rounded timer fires', async () => { + jest.useFakeTimers() + const monotonic = jest.spyOn(performance, 'now').mockReturnValue(0) + const { transport, rpc } = remoteReaderFixture([label(1)]) + const view = (await openRemoteSnapshot(transport, { lifetimeMs: 1000 }))! + try { + monotonic.mockReturnValue(999.1) + await expect(view.readPage('txLabels')).rejects.toThrow('Remote snapshot expired') + await view.closed + expect(rpc.mock.calls.filter(([method]) => method === 'readSnapshotArchivePage')).toHaveLength(0) + expect(rpc.mock.calls.filter(([method]) => method === 'cancelSnapshotArchiveRequest')).toHaveLength(1) + expect(jest.getTimerCount()).toBe(0) + } finally { + await view.close() + jest.useRealTimers() + } +}) + +test.each(['expiry', 'read'] as const)( + '%s failure preserves a separate failed cleanup acknowledgement', + async cause => { + jest.useFakeTimers() + const monotonic = jest.spyOn(performance, 'now').mockReturnValue(0) + const { transport } = remoteReaderFixture([label(1)]) + const view = (await openRemoteSnapshot(transport, { lifetimeMs: 1000 }))! + const cleanup = new Error('synthetic cancellation acknowledgement failure') + const original = new Error('synthetic authenticated frame failure') + const cancel = jest.spyOn(transport, 'cancelRequest').mockRejectedValue(cleanup) + try { + if (cause === 'expiry') monotonic.mockReturnValue(999.1) + else jest.spyOn(transport, 'page').mockRejectedValue(original) + const read = view.readPage('txLabels') + if (cause === 'expiry') await expect(read).rejects.toThrow('Remote snapshot expired') + else await expect(read).rejects.toBe(original) + await expect(view.closed).rejects.toBe(cleanup) + await expect(view.close()).rejects.toBe(cleanup) + expect(cancel).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + } finally { + await view.close().catch(() => undefined) + jest.useRealTimers() + } + } +) + +test('composite certificate cursors bind each key and accept the inclusive Unicode field-name limit', async () => { + const fieldName = '😀'.repeat(100) + const rows = [1, 2, 3].map(certificateId => ({ + created_at: new Date('2026-01-01T00:00:00.000Z'), + updated_at: new Date('2026-01-01T00:00:00.000Z'), + userId: 7, + certificateId, + fieldName, + fieldValue: 'value', + masterKey: 'synthetic key' + })) + const { transport, rpc } = remoteReaderFixture(rows, 'certificateFields', 2) + const view = (await openRemoteSnapshot(transport))! + try { + const first = await view.readPage('certificateFields', undefined, { maxRows: 1 }) + expect(first.rows).toEqual([rows[0]]) + expect(first.cursor!.after).toEqual([fieldName, 1]) + const reads = () => rpc.mock.calls.filter(([method]) => method === 'readSnapshotArchivePage').length + const initialReads = reads() + for (const after of [ + [fieldName + 'x', 1], + ['a'.repeat(101), 1], + [1, 1], + [fieldName, 0], + [fieldName, 1.5], + [fieldName, '1'] + ]) { + await expect(view.readPage('certificateFields', { ...first.cursor!, after })).rejects.toThrow('verified position') + expect(reads()).toBe(initialReads) + } + for (const after of [ + ['other', 1], + [fieldName, 2] + ]) + await expect(view.readPage('certificateFields', { ...first.cursor!, after })).rejects.toThrow('verified position') + const final = await view.readPage('certificateFields', first.cursor) + expect(final.rows).toEqual(rows.slice(1)) + expect(final.cursor!.after).toEqual([fieldName, 3]) + expect(final.cursor!.archivePosition).toEqual({ + version: 1, + archiveId: first.cursor!.archivePosition!.archiveId, + sequence: first.cursor!.archivePosition!.sequence + 1, + rowOffset: 1 + }) + expect(final.done).toBe(true) + } finally { + await view.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts new file mode 100644 index 000000000..58b65001f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts @@ -0,0 +1,350 @@ +import { StorageClient } from '../../remoting/StorageClient' +import { StorageClient as StorageMobile } from '../../remoting/StorageMobile' +import type { WalletStorageServerOptions } from '../../remoting/StorageServer' +import { snapshotHttpFixture, gate } from '../../../../test/utils/snapshotArchiveHttpFixtures' +import type { WalletReadSnapshot } from '../WalletReadSnapshot' +import { SnapshotCancelledError } from '../SnapshotCancelledError' +import { snapshotArchiveTables } from './SnapshotArchive' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { WalletStorageManager } from '../../WalletStorageManager' + +afterEach(() => jest.restoreAllMocks()) + +// Opt this controlled HTTP fixture into the in-progress capability. Production +// advertisement stays disabled until the complete lifecycle qualification passes. +async function serveReader( + fixture: Awaited>, + options: Partial = {} +) { + const result = await fixture.serve(options) + const advertise = Reflect.get(result.server, 'snapshotArchiveSettings').bind(result.server) + jest.spyOn(result.server as never, 'snapshotArchiveSettings' as never).mockImplementation((async () => { + const settings = await advertise() + return settings.snapshotArchive === undefined ? settings : { ...settings, snapshotArchiveReaderVersion: 1 } + }) as never) + return result +} + +function rpcBody(init: RequestInit | undefined): { method: string; params: unknown[] } | undefined { + if (typeof init?.body !== 'string') return undefined + const body = JSON.parse(init.body) + return typeof body.method === 'string' ? body : undefined +} + +test.each([StorageClient, StorageMobile])( + 'authenticated %p reader negotiates its first open and keeps the original archive through replacement', + async Client => { + const fixture = await snapshotHttpFixture() + let view: WalletReadSnapshot | undefined + try { + const { server, url } = await serveReader(fixture) + const client = new Client(fixture.wallet, url, { serverIdentityKey: fixture.serverIdentityKey }) + const source = client.getSnapshotSync()! + expect(client.isAvailable()).toBe(false) + const opening = jest.spyOn(fixture.storage, 'openSnapshotArchiveSource') + view = (await source.openSource(fixture.identityKey))! + expect(view.sourceStorage.storageIdentityKey).toBe('http-snapshot-source') + expect(view.user.identityKey).toBe(fixture.identityKey) + expect(view.user.created_at).toBeInstanceOf(Date) + expect(opening).toHaveBeenCalledTimes(1) + expect(Reflect.get(fixture.storage, 'snapshotSyncSource')).toBeUndefined() + const first = await view.readPage('txLabels', undefined, { maxRows: 1 }) + expect(first.done).toBe(false) + expect(first.cursor?.archivePosition).toMatchObject({ version: 1, rowOffset: 1 }) + const original = first.rows[0].label + first.rows[0].label = 'caller mutation' + await fixture.storage.knex('tx_labels').where({ txLabelId: 1 }).update({ label: 'foreground after capture' }) + expect((await view.readPage('txLabels', undefined, { maxRows: 1 })).rows[0].label).toBe(original) + const next = await view.readPage('txLabels', first.cursor, { maxRows: 1 }) + expect(next.rows).toHaveLength(1) + expect(next.rows[0].txLabelId).not.toBe(first.rows[0].txLabelId) + await server.close() + await serveReader(fixture, { port: Number(new URL(url).port) }) + const outputs = await view.readPage('outputs') + expect(outputs.rows.length).toBeGreaterThan(0) + expect(outputs.rows[0].lockingScript).toBeInstanceOf(Uint8Array) + expect(outputs.rows[0]).not.toHaveProperty('outputDescription') + for (const table of snapshotArchiveTables) { + const page = await view.readPage(table) + expect(page.done).toBe(true) + for (const row of page.rows) { + expect(row.created_at).toBeInstanceOf(Date) + expect(row.updated_at).toBeInstanceOf(Date) + if ('userId' in row) expect(row.userId).toBe(view.user.userId) + } + } + expect(opening).toHaveBeenCalledTimes(1) + await view.close() + await view.close() + expect(await fixture.storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect(await fixture.storage.knex('snapshot_archive_pages')).toHaveLength(0) + expect(await fixture.storage.knex('snapshot_archive_capacity').first()).toMatchObject({ + archives: 0, + reservedBytes: 0 + }) + } finally { + await view?.close().catch(() => undefined) + await fixture.close() + } + } +) + +test.each(['before', 'after'] as const)( + 'native fetch loss %s admission retries the identical tuple once through real authentication', + async lost => { + const fixture = await snapshotHttpFixture() + const nativeFetch = globalThis.fetch + const admissions: unknown[][] = [] + let view: WalletReadSnapshot | undefined + try { + const { url } = await serveReader(fixture) + jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const body = rpcBody(init) + if (body?.method === 'admitSnapshotArchive') { + admissions.push(body.params) + if (admissions.length === 1) { + if (lost === 'after') { + const response = await nativeFetch(input, init) + await response.body?.cancel() + } + throw new Error('synthetic native fetch connection loss') + } + } + return await nativeFetch(input, init) + }) + const client = new StorageClient(fixture.wallet, url) + const opening = jest.spyOn(fixture.storage, 'openSnapshotArchiveSource') + view = (await client.getSnapshotSync()!.openSource(fixture.identityKey))! + expect(view.isOpen).toBe(true) + expect(admissions).toHaveLength(2) + expect(admissions[0]).toEqual(admissions[1]) + expect(opening).toHaveBeenCalledTimes(1) + expect((await view.readPage('txLabels')).rows).toHaveLength(2) + await view.close() + expect(await fixture.storage.knex('snapshot_archive_requests')).toHaveLength(0) + } finally { + await view?.close().catch(() => undefined) + await fixture.close() + } + } +) + +test('an unauthenticated admission reply is not retried or treated as compatibility fallback', async () => { + const fixture = await snapshotHttpFixture() + const nativeFetch = globalThis.fetch + let admissions = 0 + try { + const { url } = await serveReader(fixture) + jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const response = await nativeFetch(input, init) + if (rpcBody(init)?.method !== 'admitSnapshotArchive') return response + admissions++ + const headers = new Headers(response.headers) + headers.delete('x-bsv-auth-signature') + return new Response(await response.arrayBuffer(), { status: response.status, headers }) + }) + const client = new StorageClient(fixture.wallet, url) + await expect(client.getSnapshotSync()!.openSource(fixture.identityKey)).rejects.toThrow('authentication') + expect(admissions).toBe(1) + expect(await fixture.storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect(Reflect.get(fixture.storage, 'snapshotSyncSource')).toBeUndefined() + } finally { + await fixture.close() + } +}) + +test.each( + ['getSnapshotArchiveDirectory', 'readSnapshotArchivePage', 'cancelSnapshotArchiveRequest'].flatMap(method => + (['before', 'after'] as const).map(loss => ({ method, loss })) + ) +)('immutable $method recovers native loss $loss the request with one exact retry', async ({ method, loss }) => { + const fixture = await snapshotHttpFixture() + const nativeFetch = globalThis.fetch + const calls: unknown[][] = [] + let view: WalletReadSnapshot | undefined + try { + const { url } = await serveReader(fixture) + jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const body = rpcBody(init) + if (body?.method === method) { + calls.push(body.params) + if (calls.length === 1) { + if (loss === 'after') { + const response = await nativeFetch(input, init) + await response.body?.cancel() + } + throw new Error('synthetic native fetch connection loss') + } + } + return await nativeFetch(input, init) + }) + const opening = jest.spyOn(fixture.storage, 'openSnapshotArchiveSource') + const client = new StorageClient(fixture.wallet, url) + view = (await client.getSnapshotSync()!.openSource(fixture.identityKey))! + const expiry = view.expiresAt + expect((await view.readPage('txLabels')).rows).toHaveLength(2) + expect(view.expiresAt).toBe(expiry) + await view.close() + expect(calls).toHaveLength(2) + expect(calls[0]).toEqual(calls[1]) + expect(opening).toHaveBeenCalledTimes(1) + expect(await fixture.storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect(await fixture.storage.knex('snapshot_archive_capacity').first()).toMatchObject({ + archives: 0, + reservedBytes: 0 + }) + } finally { + await view?.close().catch(() => undefined) + await fixture.close() + } +}) + +test('a page authentication failure closes the view without retrying its content request', async () => { + const fixture = await snapshotHttpFixture() + const nativeFetch = globalThis.fetch + let pages = 0 + let view: WalletReadSnapshot | undefined + try { + const { url } = await serveReader(fixture) + jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const response = await nativeFetch(input, init) + if (rpcBody(init)?.method !== 'readSnapshotArchivePage') return response + pages++ + const headers = new Headers(response.headers) + headers.delete('x-bsv-auth-signature') + return new Response(await response.arrayBuffer(), { status: response.status, headers }) + }) + view = (await new StorageClient(fixture.wallet, url).getSnapshotSync()!.openSource(fixture.identityKey))! + await expect(view.readPage('txLabels')).rejects.toThrow('authentication') + expect(pages).toBe(1) + expect(view.isOpen).toBe(false) + await view.closed + expect(await fixture.storage.knex('snapshot_archive_requests')).toHaveLength(0) + } finally { + await view?.close().catch(() => undefined) + await fixture.close() + } +}) + +test('cancellation during authenticated admission preserves its local type and drains the owned source', async () => { + const fixture = await snapshotHttpFixture() + const entered = gate() + const resume = gate() + const controller = new AbortController() + let pending: Promise | undefined + try { + const { url } = await serveReader(fixture) + const open = fixture.storage.openSnapshotArchiveSource.bind(fixture.storage) + jest.spyOn(fixture.storage, 'openSnapshotArchiveSource').mockImplementation(async (...args) => { + entered.resolve() + await resume.promise + return await open(...args) + }) + const client = new StorageMobile(fixture.wallet, url) + pending = client.getSnapshotSync()!.openSource(fixture.identityKey, { signal: controller.signal }) + void pending.catch(() => undefined) + await entered.promise + controller.abort() + resume.resolve() + await expect(pending).rejects.toBeInstanceOf(SnapshotCancelledError) + expect(Reflect.get(fixture.storage, 'snapshotSyncSource')).toBeUndefined() + expect(await fixture.storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect(await fixture.storage.knex('snapshot_archive_capacity').first()).toMatchObject({ + archives: 0, + reservedBytes: 0 + }) + } finally { + resume.resolve() + await pending?.then( + view => view?.close(), + () => undefined + ) + await fixture.close() + } +}) + +test.each([StorageClient, StorageMobile])( + 'authenticated %p sync commits archive positions and resumes cancellation into an occupied destination', + async Client => { + const fixture = await snapshotHttpFixture(true, false) + const directory = await mkdtemp(join(tmpdir(), 'snapshot-http-destination-')) + const destination = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + snapshotSync: true, + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + }) + try { + const { url } = await serveReader(fixture) + const sourceUser = (await fixture.storage.findUserByIdentityKey(fixture.identityKey))! + await fixture.storage.updateUser(sourceUser.userId, { activeStorage: 'http-snapshot-source' }) + for (let index = 0; index < 5; index++) { + await fixture.storage.insertTxLabel({ + txLabelId: 0, + userId: sourceUser.userId, + label: `label-${index}`, + isDeleted: index % 2 === 0, + created_at: new Date('2026-01-01T00:00:00.000Z'), + updated_at: new Date('2026-01-01T00:00:00.000Z') + }) + } + await destination.knex.raw('PRAGMA journal_mode = WAL') + await destination.migrate('HTTP destination', 'http-destination') + await destination.makeAvailable() + const { user: foreign } = await destination.findOrInsertUser(fixture.serverIdentityKey) + await destination.findOrInsertTxLabel(foreign.userId, 'foreign-existing') + const { user } = await destination.findOrInsertUser(fixture.identityKey) + await destination.updateUser(user.userId, { activeStorage: 'http-destination' }) + const manager = new WalletStorageManager(fixture.identityKey, destination) + await manager.makeAvailable() + const reader = new Client(fixture.wallet, url) + const legacy = jest.spyOn(destination, 'processSyncChunk') + const controller = new AbortController() + const partial = await manager.syncFromReaderResumable(fixture.identityKey, reader, { + maxItems: 2, + signal: controller.signal, + onProgress: progress => { + if (progress.snapshotCheckpoint?.cursor?.table === 'txLabels') controller.abort() + } + }) + expect(partial.status).toBe('cancelled') + expect(partial.snapshotCheckpoint?.cursor?.archivePosition).toMatchObject({ version: 1, rowOffset: 2 }) + expect(partial.snapshotCheckpoint).toEqual( + await destination.getSnapshotSync()!.checkpoint(fixture.identityKey, 'http-snapshot-source') + ) + expect(await destination.findTxLabels({ partial: { userId: user.userId } })).toHaveLength(2) + expect(await fixture.storage.knex('snapshot_archive_requests')).toHaveLength(0) + const complete = await manager.syncFromReaderResumable(fixture.identityKey, reader, { maxItems: 2 }) + expect(complete.status).toBe('completed') + expect(complete.snapshotCheckpoint?.done).toBe(true) + expect(complete.snapshotCheckpoint?.snapshotId).not.toBe(partial.snapshotCheckpoint?.snapshotId) + const labels = await destination.findTxLabels({ partial: { userId: user.userId } }) + expect(labels.map(row => [row.label, row.isDeleted]).sort()).toEqual( + Array.from({ length: 5 }, (_, index) => [`label-${index}`, index % 2 === 0]) + ) + expect((await destination.findTxLabels({ partial: { userId: foreign.userId } })).map(row => row.label)).toEqual([ + 'foreign-existing' + ]) + expect((await destination.findUserByIdentityKey(fixture.identityKey))!.activeStorage).toBe('http-destination') + expect(legacy).not.toHaveBeenCalled() + expect(await fixture.storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect(await fixture.storage.knex('snapshot_archive_capacity').first()).toMatchObject({ + archives: 0, + reservedBytes: 0 + }) + } finally { + await destination.destroy() + await fixture.close() + await rm(directory, { recursive: true, force: true }) + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.test.ts new file mode 100644 index 000000000..0dc46acd4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.test.ts @@ -0,0 +1,340 @@ +import type { PackedSnapshotRow, WalletSnapshotTables } from '../WalletReadSnapshot' +import { encodeSyncTransfer } from '../../remoting/SyncTransfer' +import { decodeRemoteSnapshotFrame, detachRemoteSnapshotRow, remoteSnapshotKeys } from './RemoteSnapshotRows' +import { snapshotArchiveLimits } from './SnapshotArchive' +import type { SnapshotArchiveReceipt } from './SnapshotArchiveDirectory' +import { label } from '../../../../test/utils/remoteSnapshotReaderFixtures' + +const receipt: SnapshotArchiveReceipt = { sequence: 0, table: 'txLabels', rows: 1, done: true, digest: 'a'.repeat(64) } +const encode = (rows: unknown[], changes: object = {}) => + encodeSyncTransfer({ version: 1, table: 'txLabels', rows, ...changes }) + +test('a raw row frame reconstructs fresh dates, retains exact Unicode and charges its payload budget', () => { + const row = label(1, 'é\u0000😀') + const decoded = decodeRemoteSnapshotFrame(encode([row]), receipt, 7) + expect(decoded.rows).toEqual([row]) + expect(decoded.charges).toEqual([6 * 64 + row.label.length * 2]) + const first = detachRemoteSnapshotRow(decoded.rows[0]) + const second = detachRemoteSnapshotRow(decoded.rows[0]) + expect(first).toEqual(row) + expect(second).toEqual(row) + expect(first.created_at).not.toBe(second.created_at) + ;(first.created_at as Date).setTime(0) + first.label = 'caller mutation' + expect(detachRemoteSnapshotRow(decoded.rows[0])).toEqual(row) +}) + +test('transaction bytes remain packed and detached, including the optional no-send recovery field', () => { + const row = { + created_at: new Date('2026-01-01T00:00:00.000Z'), + updated_at: new Date('2026-01-02T00:00:00.000Z'), + transactionId: 3, + userId: 7, + status: 'completed', + reference: 'reference', + isOutgoing: true, + satoshis: 1, + description: 'synthetic transaction', + rawTx: Uint8Array.of(0, 255), + noSendExpiryReclaimRawTx: Uint8Array.of(1, 128, 255) + } + const result = decodeRemoteSnapshotFrame( + encodeSyncTransfer({ version: 1, table: 'transactions', rows: [row] }), + { ...receipt, table: 'transactions' }, + 7 + ) + expect(result.rows).toEqual([row]) + expect(result.charges).toEqual([ + Object.values(row).reduce( + (sum, value) => + sum + 64 + (typeof value === 'string' ? value.length * 2 : value instanceof Uint8Array ? value.length * 2 : 0), + 0 + ) + ]) + const first = detachRemoteSnapshotRow(result.rows[0]) + ;(first.rawTx as Uint8Array).fill(42) + ;(first.noSendExpiryReclaimRawTx as Uint8Array).fill(42) + expect(detachRemoteSnapshotRow(result.rows[0])).toEqual(row) + expect(Array.isArray(result.rows[0].rawTx)).toBe(false) +}) + +test('unknown columns, foreign profiles, missing required fields and wrong cell representations reject', () => { + const invalid = [ + { ...label(1), userId: 8 }, + { ...label(1), userId: 0 }, + { ...label(1), txLabelId: 1.5 }, + { ...label(1), isDeleted: 1 }, + { ...label(1), label: 1 }, + { ...label(1), extra: 'column' }, + { ...label(1), updated_at: '2026-01-01' }, + { ...label(1), created_at: 'not-a-date' }, + { ...label(1), created_at: 0 }, + { ...label(1), label: null }, + Object.fromEntries(Object.entries(label(1)).filter(([name]) => name !== 'isDeleted')), + Object.assign(label(1), Object.fromEntries(Array.from({ length: 65 }, (_, n) => [`extra${n}`, n]))) + ] + for (const row of invalid) + expect(() => decodeRemoteSnapshotFrame(encode([row]), receipt, 7)).toThrow('Invalid snapshot archive row frame') +}) + +test('a frame must match its authenticated table and exact row count within hard byte and row bounds', () => { + for (const bytes of [new Uint8Array(0), new Uint8Array(snapshotArchiveLimits.pageBytes + 1)]) { + expect(() => decodeRemoteSnapshotFrame(bytes, receipt, 7)).toThrow('Invalid snapshot archive row frame') + } + for (const fields of [{ version: 2 }, { table: 'outputs' }, { extra: true }, { rows: {} }]) { + expect(() => decodeRemoteSnapshotFrame(encode([label(1)], fields), receipt, 7)).toThrow( + 'Invalid snapshot archive row frame' + ) + } + expect(() => decodeRemoteSnapshotFrame(encode([]), receipt, 7)).toThrow('Invalid snapshot archive row frame') + const large = Array.from({ length: snapshotArchiveLimits.rowsPerPage + 1 }, (_, n) => label(n + 1)) + expect(() => decodeRemoteSnapshotFrame(encode(large), { ...receipt, rows: large.length }, 7)).toThrow( + 'Invalid snapshot archive row frame' + ) + expect(decodeRemoteSnapshotFrame(encode([]), { ...receipt, rows: 0 }, 7)).toEqual({ rows: [], charges: [] }) +}) + +test('source-key declarations are detached and inherited table names never select a schema', () => { + const keys = remoteSnapshotKeys('certificateFields') as string[] + expect(keys).toEqual(['fieldName', 'certificateId']) + keys[0] = 'caller replacement' + expect(remoteSnapshotKeys('certificateFields')).toEqual(['fieldName', 'certificateId']) + for (const name of ['toString', '__proto__', 'constructor', 'unknown']) { + expect(() => remoteSnapshotKeys(name as 'txLabels')).toThrow('Invalid snapshot archive row frame') + } +}) + +test.each([null, 7, 'row', [], Uint8Array.of(1)])('non-record row %p cannot be returned as wallet data', input => { + expect(() => decodeRemoteSnapshotFrame(encode([input]), receipt, 7)).toThrow('Invalid snapshot archive row frame') +}) + +test('binary, numeric and certificate-field values retain strict representations and inclusive Unicode bounds', () => { + const when = new Date('2026-01-01T00:00:00.000Z') + const proven = { + created_at: when, + updated_at: when, + provenTxId: 1, + txid: 'transaction', + height: 0, + index: 0, + merklePath: new Uint8Array(), + rawTx: new Uint8Array(), + blockHash: 'block', + merkleRoot: 'root' + } + const decode = (table: 'provenTxs' | 'certificateFields', input: object) => + decodeRemoteSnapshotFrame(encodeSyncTransfer({ version: 1, table, rows: [input] }), { ...receipt, table }, 7) + expect(decode('provenTxs', proven).rows).toEqual([proven]) + for (const value of [null, [], [0, 255], 'binary']) + expect(() => decode('provenTxs', { ...proven, rawTx: value })).toThrow('Invalid snapshot archive row frame') + for (const value of [null, '0', true]) + expect(() => decode('provenTxs', { ...proven, height: value })).toThrow('Invalid snapshot archive row frame') + const field = { + created_at: when, + updated_at: when, + userId: 7, + certificateId: 1, + fieldName: '😀'.repeat(100), + fieldValue: 'value', + masterKey: 'key' + } + expect(decode('certificateFields', field).rows).toEqual([field]) + for (const fieldName of [1, 'a'.repeat(101), '😀'.repeat(101)]) + expect(() => decode('certificateFields', { ...field, fieldName })).toThrow('Invalid snapshot archive row frame') +}) + +test('all thirteen public row shapes retain their columns and reject changed representations and profiles', () => { + const when = new Date('2026-01-01T00:00:00.000Z') + const timestamps = { created_at: when, updated_at: when } + const txid = '12'.repeat(32) + const identity = '02' + '34'.repeat(32) + const packed = Uint8Array.of(0, 128, 255) + type Complete = Required> + const examples: { [T in keyof WalletSnapshotTables]: Complete } = { + provenTxs: { + ...timestamps, + provenTxId: 1, + txid, + height: 0, + index: 0, + merklePath: packed, + rawTx: packed, + blockHash: txid, + merkleRoot: txid + }, + outputBaskets: { + ...timestamps, + basketId: 1, + userId: 7, + name: 'default', + numberOfDesiredUTXOs: 10, + minimumDesiredUTXOValue: 1000, + isDeleted: false + }, + commissions: { + ...timestamps, + commissionId: 1, + userId: 7, + transactionId: 1, + satoshis: 1, + keyOffset: 'synthetic offset', + isRedeemed: false, + lockingScript: packed + }, + outputTags: { ...timestamps, outputTagId: 1, userId: 7, tag: 'tag', isDeleted: false }, + outputTagMaps: { ...timestamps, outputTagId: 1, outputId: 2, isDeleted: false }, + txLabels: label(1), + txLabelMaps: { ...timestamps, txLabelId: 1, transactionId: 2, isDeleted: false }, + certificateFields: { + ...timestamps, + userId: 7, + certificateId: 1, + fieldName: 'name', + fieldValue: 'value', + masterKey: 'synthetic key' + }, + transactions: { + ...timestamps, + transactionId: 1, + userId: 7, + provenTxId: 2, + status: 'completed', + reference: 'cmVm', + isOutgoing: true, + satoshis: 1000, + description: 'synthetic history', + version: 2, + lockTime: 0, + txid, + inputBEEF: packed, + rawTx: packed, + noSendExpiryMode: 'seconds', + noSendExpiryValue: 60, + noSendExpiryDeadline: 1790812860, + noSendExpiryState: 'reclaimed', + noSendExpiryAnchorTxid: txid, + noSendExpiryAnchorVout: 1, + noSendExpiryReleasedAt: 1790812861, + noSendExpiryObservedAt: 1790812862, + noSendExpiryReclaimTxid: txid, + noSendExpiryReclaimRawTx: packed, + noSendExpiryReclaimDerivationPrefix: 'cHJlZml4', + noSendExpiryReclaimDerivationSuffix: 'c3VmZml4', + noSendExpiryReclaimSatoshis: 500 + }, + outputs: { + ...timestamps, + outputId: 1, + userId: 7, + transactionId: 1, + basketId: 1, + spendable: false, + change: false, + outputDescription: 'synthetic output', + vout: 0, + satoshis: 1000, + providedBy: 'you-and-storage', + purpose: 'purpose', + type: 'P2PKH', + txid, + senderIdentityKey: identity, + derivationPrefix: 'cHJlZml4', + derivationSuffix: 'c3VmZml4', + customInstructions: 'retained metadata', + spentBy: 2, + sequenceNumber: 0, + spendingDescription: 'spent description', + scriptLength: 3, + scriptOffset: 0, + lockingScript: packed + }, + provenTxReqs: { + ...timestamps, + provenTxReqId: 1, + provenTxId: 2, + status: 'completed', + attempts: 0, + notified: true, + txid, + batch: 'batch', + history: '{}', + notify: '{}', + rawTx: packed, + inputBEEF: packed, + wasBroadcast: false, + rebroadcastAttempts: 0 + }, + certificates: { + ...timestamps, + certificateId: 1, + userId: 7, + type: 'dHlwZQ==', + serialNumber: 'c2VyaWFs', + certifier: identity, + subject: identity, + verifier: identity, + revocationOutpoint: txid + '.0', + signature: '1234', + isDeleted: false + }, + syncStates: { + ...timestamps, + syncStateId: 1, + userId: 7, + storageIdentityKey: identity, + storageName: 'original storage', + status: 'success', + init: true, + refNum: 'reference', + syncMap: '{}', + when, + satoshis: 1000, + errorLocal: 'historical local error', + errorOther: 'historical remote error' + } + } + for (const [name, example] of Object.entries(examples)) { + const table = name as keyof typeof examples + const decode = (row: object) => + decodeRemoteSnapshotFrame(encodeSyncTransfer({ version: 1, table, rows: [row] }), { ...receipt, table }, 7) + expect(decode(example).rows).toEqual([example]) + if ('userId' in example) + expect(() => decode({ ...example, userId: 8 })).toThrow('Invalid snapshot archive row frame') + for (const [field, value] of Object.entries(example)) { + // Derive only the invalid representation from independently typed public + // fixtures, without reading the decoder's private schema declaration. + const wrong = typeof value === 'string' ? 1 : typeof value === 'boolean' ? 0 : 'wrong representation' + expect(() => decode({ ...example, [field]: wrong })).toThrow('Invalid snapshot archive row frame') + } + } +}) + +test('every source table preserves the established scalar or composite key order', () => { + const expected: Record = { + provenTxs: ['provenTxId'], + provenTxReqs: ['provenTxReqId'], + outputBaskets: ['basketId'], + transactions: ['transactionId'], + commissions: ['commissionId'], + outputs: ['outputId'], + outputTags: ['outputTagId'], + outputTagMaps: ['outputTagId', 'outputId'], + txLabels: ['txLabelId'], + txLabelMaps: ['txLabelId', 'transactionId'], + certificates: ['certificateId'], + certificateFields: ['fieldName', 'certificateId'], + syncStates: ['syncStateId'] + } + for (const [name, keys] of Object.entries(expected)) { + const table = name as keyof WalletSnapshotTables + const actual = remoteSnapshotKeys(table) as string[] + expect(actual).toEqual(keys) + actual.length = 0 + expect(remoteSnapshotKeys(table)).toEqual(keys) + } +}) + +test('the inclusive authenticated row limit accepts a full page', () => { + const rows = Array.from({ length: snapshotArchiveLimits.rowsPerPage }, (_, index) => label(index + 1)) + expect(decodeRemoteSnapshotFrame(encode(rows), { ...receipt, rows: rows.length }, 7).rows).toEqual(rows) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.ts new file mode 100644 index 000000000..6bfdd0cd2 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.ts @@ -0,0 +1,327 @@ +import { decodeSyncTransfer } from '../../remoting/SyncTransfer' +import type { WalletSnapshotTable } from '../WalletReadSnapshot' +import { snapshotArchiveLimits } from './SnapshotArchive' +import type { SnapshotArchiveReceipt } from './SnapshotArchiveDirectory' + +type Cell = string | number | boolean | Date | Uint8Array +type Kind = 'string' | 'number' | 'boolean' | 'date' | 'bytes' | 'id' | 'fieldName' +export type RemoteSnapshotRow = Readonly> +interface Schema { + keys: readonly string[] + owned: boolean + required: Readonly> + optional?: Readonly> +} +const timestamps = { created_at: 'date', updated_at: 'date' } as const + +// Version-one raw SQL row shapes. This validates representation and ownership; +// portable semantics and proof authentication remain independent consumers. +const schemas: Record = { + provenTxs: { + keys: ['provenTxId'], + owned: false, + required: { + ...timestamps, + provenTxId: 'id', + txid: 'string', + height: 'number', + index: 'number', + merklePath: 'bytes', + rawTx: 'bytes', + blockHash: 'string', + merkleRoot: 'string' + } + }, + provenTxReqs: { + keys: ['provenTxReqId'], + owned: false, + required: { + ...timestamps, + provenTxReqId: 'id', + status: 'string', + attempts: 'number', + notified: 'boolean', + txid: 'string', + history: 'string', + notify: 'string', + rawTx: 'bytes' + }, + optional: { + provenTxId: 'id', + batch: 'string', + inputBEEF: 'bytes', + wasBroadcast: 'boolean', + rebroadcastAttempts: 'number' + } + }, + outputBaskets: { + keys: ['basketId'], + owned: true, + required: { + ...timestamps, + basketId: 'id', + userId: 'id', + name: 'string', + numberOfDesiredUTXOs: 'number', + minimumDesiredUTXOValue: 'number', + isDeleted: 'boolean' + } + }, + transactions: { + keys: ['transactionId'], + owned: true, + required: { + ...timestamps, + transactionId: 'id', + userId: 'id', + status: 'string', + reference: 'string', + isOutgoing: 'boolean', + satoshis: 'number', + description: 'string' + }, + optional: { + provenTxId: 'id', + version: 'number', + lockTime: 'number', + txid: 'string', + inputBEEF: 'bytes', + rawTx: 'bytes', + noSendExpiryMode: 'string', + noSendExpiryValue: 'number', + noSendExpiryDeadline: 'number', + noSendExpiryState: 'string', + noSendExpiryAnchorTxid: 'string', + noSendExpiryAnchorVout: 'number', + noSendExpiryReleasedAt: 'number', + noSendExpiryObservedAt: 'number', + noSendExpiryReclaimTxid: 'string', + noSendExpiryReclaimRawTx: 'bytes', + noSendExpiryReclaimDerivationPrefix: 'string', + noSendExpiryReclaimDerivationSuffix: 'string', + noSendExpiryReclaimSatoshis: 'number' + } + }, + commissions: { + keys: ['commissionId'], + owned: true, + required: { + ...timestamps, + commissionId: 'id', + userId: 'id', + transactionId: 'id', + satoshis: 'number', + keyOffset: 'string', + isRedeemed: 'boolean', + lockingScript: 'bytes' + } + }, + outputs: { + keys: ['outputId'], + owned: true, + required: { + ...timestamps, + outputId: 'id', + userId: 'id', + transactionId: 'id', + spendable: 'boolean', + change: 'boolean', + vout: 'number', + satoshis: 'number', + providedBy: 'string', + purpose: 'string', + type: 'string' + }, + optional: { + // SQL permits this legacy column to be null; normalized source rows omit it. + outputDescription: 'string', + basketId: 'id', + txid: 'string', + senderIdentityKey: 'string', + derivationPrefix: 'string', + derivationSuffix: 'string', + customInstructions: 'string', + spentBy: 'id', + sequenceNumber: 'number', + spendingDescription: 'string', + scriptLength: 'number', + scriptOffset: 'number', + lockingScript: 'bytes' + } + }, + outputTags: { + keys: ['outputTagId'], + owned: true, + required: { ...timestamps, outputTagId: 'id', userId: 'id', tag: 'string', isDeleted: 'boolean' } + }, + outputTagMaps: { + keys: ['outputTagId', 'outputId'], + owned: false, + required: { ...timestamps, outputTagId: 'id', outputId: 'id', isDeleted: 'boolean' } + }, + txLabels: { + keys: ['txLabelId'], + owned: true, + required: { ...timestamps, txLabelId: 'id', userId: 'id', label: 'string', isDeleted: 'boolean' } + }, + txLabelMaps: { + keys: ['txLabelId', 'transactionId'], + owned: false, + required: { ...timestamps, txLabelId: 'id', transactionId: 'id', isDeleted: 'boolean' } + }, + certificates: { + keys: ['certificateId'], + owned: true, + required: { + ...timestamps, + certificateId: 'id', + userId: 'id', + type: 'string', + serialNumber: 'string', + certifier: 'string', + subject: 'string', + revocationOutpoint: 'string', + signature: 'string', + isDeleted: 'boolean' + }, + optional: { verifier: 'string' } + }, + certificateFields: { + keys: ['fieldName', 'certificateId'], + owned: true, + required: { + ...timestamps, + userId: 'id', + certificateId: 'id', + fieldName: 'fieldName', + fieldValue: 'string', + masterKey: 'string' + } + }, + syncStates: { + keys: ['syncStateId'], + owned: true, + required: { + ...timestamps, + syncStateId: 'id', + userId: 'id', + storageIdentityKey: 'string', + storageName: 'string', + status: 'string', + init: 'boolean', + refNum: 'string', + syncMap: 'string' + }, + optional: { when: 'date', satoshis: 'number', errorLocal: 'string', errorOther: 'string' } + } +} + +function invalid(): never { + throw new TypeError('Invalid snapshot archive row frame') +} + +export function remoteSnapshotKeys(table: WalletSnapshotTable): readonly string[] { + if (!Object.hasOwn(schemas, table)) invalid() + return [...schemas[table].keys] +} + +function record(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) invalid() + // This private helper only receives decoded bytes. JSON parsing and binary + // restoration create enumerable string-keyed data properties, never accessors + // or symbols; arbitrary caller objects are validated at the cursor boundary. + if (Object.keys(value).length > 64) invalid() + return Object.fromEntries(Object.entries(value)) +} + +function cell(value: unknown, kind: Kind): Cell { + switch (kind) { + case 'bytes': + if (!(value instanceof Uint8Array)) invalid() + return value + case 'date': { + if (typeof value !== 'string' || value.length > 32) invalid() + const date = new Date(value) + if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) invalid() + return date + } + case 'id': + if (!Number.isSafeInteger(value) || (value as number) < 1) invalid() + return value as number + case 'fieldName': + if (typeof value !== 'string' || value.length > 200 || Array.from(value).length > 100) invalid() + return value + case 'number': + if (typeof value !== 'number' || !Number.isFinite(value)) invalid() + return value + case 'boolean': + if (typeof value !== 'boolean') invalid() + return value + case 'string': + if (typeof value !== 'string') invalid() + return value + } +} + +function row(input: unknown, schema: Schema, userId: number): RemoteSnapshotRow { + const fields = record(input) + for (const name of Object.keys(schema.required)) if (!Object.hasOwn(fields, name)) invalid() + const result: Record = {} + for (const [name, value] of Object.entries(fields)) { + const kind = Object.hasOwn(schema.required, name) + ? schema.required[name] + : schema.optional !== undefined && Object.hasOwn(schema.optional, name) + ? schema.optional[name] + : undefined + if (kind === undefined) invalid() + Object.defineProperty(result, name, { value: cell(value, kind), enumerable: true }) + } + if (schema.owned && result.userId !== userId) invalid() + return result +} + +export interface RemoteSnapshotFrame { + readonly rows: readonly RemoteSnapshotRow[] + readonly charges: readonly number[] +} + +/** Decode one independently bounded frame only after its receipt hash was verified. */ +export function decodeRemoteSnapshotFrame( + bytes: Uint8Array, + receipt: SnapshotArchiveReceipt, + userId: number +): RemoteSnapshotFrame { + if (bytes.length < 1 || bytes.length > snapshotArchiveLimits.pageBytes) invalid() + remoteSnapshotKeys(receipt.table) + const frame = record(decodeSyncTransfer(bytes)) + if ( + Object.keys(frame).length !== 3 || + frame.version !== 1 || + frame.table !== receipt.table || + !Array.isArray(frame.rows) || + frame.rows.length !== receipt.rows || + frame.rows.length > snapshotArchiveLimits.rowsPerPage + ) + invalid() + const rows = frame.rows.map(input => row(input, schemas[receipt.table], userId)) + const charges = rows.map(value => + Object.values(value).reduce( + (sum, entry) => + sum + + 64 + + (typeof entry === 'string' ? entry.length * 2 : entry instanceof Uint8Array ? entry.byteLength * 2 : 0), + 0 + ) + ) + return { rows, charges } +} + +/** The cached frame remains private; callers own every returned mutable value. */ +export function detachRemoteSnapshotRow(value: RemoteSnapshotRow): Record { + return Object.fromEntries( + Object.entries(value).map(([name, entry]) => [ + name, + entry instanceof Uint8Array ? new Uint8Array(entry) : entry instanceof Date ? new Date(entry.getTime()) : entry + ]) + ) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveAdmission.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveAdmission.test.ts new file mode 100644 index 000000000..89568276a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveAdmission.test.ts @@ -0,0 +1,77 @@ +import { validateSnapshotArchiveAdmission, SnapshotArchiveAdmissionLimitError } from './SnapshotArchiveAdmission' +import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' + +const fields = { version: 1 as const, nonce: 'a'.repeat(64), notAfter: 1790812800000, maxBytes: 32768 } +const request = { ...fields, requestId: snapshotArchiveRequestId(fields) } +const receipt = { version: 1, requestId: request.requestId, expiresAt: request.notAfter, state: 'building' } +const refused = { version: 1, outcome: 'resource-limited', requestId: request.requestId, expiresAt: request.notAfter } + +test('accepted and transient refused outcomes are distinct, detached and immutable', () => { + const accepted = validateSnapshotArchiveAdmission({ version: 1, outcome: 'accepted', receipt }, request) + expect(accepted).toEqual({ version: 1, outcome: 'accepted', receipt }) + expect(Object.isFrozen(accepted)).toBe(true) + if (accepted.outcome !== 'accepted') throw new Error('Expected accepted fixture') + expect(accepted.receipt).not.toBe(receipt) + expect(Object.isFrozen(accepted.receipt)).toBe(true) + const result = validateSnapshotArchiveAdmission(refused, request) + expect(result).toEqual(refused) + expect(result).not.toBe(refused) + expect(Object.isFrozen(result)).toBe(true) + expect(result).not.toHaveProperty('receipt') + expect(new SnapshotArchiveAdmissionLimitError('occupied')).toBeInstanceOf(SnapshotResourceLimitError) +}) + +test.each([null, undefined, [], true, 1, 'accepted'])('rejects non-record admission %p', value => { + expect(() => validateSnapshotArchiveAdmission(value, request)).toThrow('Invalid snapshot archive admission') +}) + +test.each([ + { version: 2 }, + { outcome: 'ready' }, + { requestId: 'b'.repeat(64) }, + { expiresAt: request.notAfter + 1 }, + { receipt }, + { claimToken: 'unwanted' }, + { [Symbol('extra')]: true } +])('refusal binds exactly to the original request and field set %p', fields => { + expect(() => validateSnapshotArchiveAdmission({ ...refused, ...fields }, request)).toThrow( + 'Invalid snapshot archive admission' + ) +}) + +test('own enumerable data fields are required without invoking getters', () => { + for (const name of Object.keys(refused)) { + const missing = { ...refused } as Record + delete missing[name] + expect(() => validateSnapshotArchiveAdmission(missing, request)).toThrow( + 'Invalid snapshot archive admission outcome' + ) + Object.defineProperty(missing, name, { value: Reflect.get(refused, name), configurable: true }) + expect(() => validateSnapshotArchiveAdmission(missing, request)).toThrow( + 'Invalid snapshot archive admission outcome' + ) + Object.defineProperty(missing, name, { + get: () => { + throw new Error('Getter must not run') + }, + enumerable: true + }) + expect(() => validateSnapshotArchiveAdmission(missing, request)).toThrow('Invalid snapshot archive admission') + } + expect(() => validateSnapshotArchiveAdmission(Object.create(refused), request)).toThrow( + 'Invalid snapshot archive admission outcome' + ) + expect(() => + validateSnapshotArchiveAdmission( + { version: 1, outcome: 'accepted', receipt: { ...receipt, expiresAt: 1 } }, + request + ) + ).toThrow('Invalid snapshot archive protocol') +}) + +test.each([null, undefined, true, 1, 'request', {}])('an admission cannot bind to invalid request %p', value => { + expect(() => validateSnapshotArchiveAdmission(refused, value as unknown as typeof request)).toThrow( + 'Invalid snapshot archive creation request' + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveAdmission.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveAdmission.ts new file mode 100644 index 000000000..4c1afc5a1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveAdmission.ts @@ -0,0 +1,60 @@ +import { parseSnapshotArchiveReaderRequest, type SnapshotArchiveReaderRequest } from './SnapshotArchiveReaderRequest' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { + parseSnapshotArchiveRequest, + type SnapshotArchiveRequest, + type SnapshotArchiveRequestReceipt +} from './SnapshotArchiveRequest' +import { validateSnapshotArchiveRequestReceipt } from './SnapshotArchiveProtocol' + +/** Only pre-admission capacity refusals carry this type; cleanup errors do not. */ +export class SnapshotArchiveAdmissionLimitError extends SnapshotResourceLimitError {} + +export type SnapshotArchiveAdmission = + | { version: 1; outcome: 'accepted'; receipt: Readonly } + | { version: 1; outcome: 'resource-limited'; requestId: string; expiresAt: number } + +function invalid(): never { + throw new TypeError('Invalid snapshot archive admission outcome') +} + +/** A refusal is transient and never stands in for an immutable durable receipt. */ +export function validateSnapshotArchiveAdmission( + input: unknown, + value: SnapshotArchiveRequest | SnapshotArchiveReaderRequest +): Readonly { + const version = + value !== null && typeof value === 'object' ? Object.getOwnPropertyDescriptor(value, 'version')?.value : undefined + const request = version === 2 ? parseSnapshotArchiveReaderRequest(value) : parseSnapshotArchiveRequest(value) + if (input === null || typeof input !== 'object' || Array.isArray(input)) invalid() + const property = Object.getOwnPropertyDescriptor(input, 'outcome') + if (property === undefined || !('value' in property)) invalid() + const names = + property.value === 'accepted' ? ['version', 'outcome', 'receipt'] : ['version', 'outcome', 'requestId', 'expiresAt'] + if (Reflect.ownKeys(input).length !== names.length) invalid() + const fields: Record = {} + for (const name of names) { + const item = Object.getOwnPropertyDescriptor(input, name) + if (item === undefined || !('value' in item) || !item.enumerable) invalid() + fields[name] = item.value + } + if (fields.version !== 1) invalid() + if (fields.outcome === 'accepted') + return Object.freeze({ + version: 1, + outcome: 'accepted', + receipt: validateSnapshotArchiveRequestReceipt(fields.receipt, request) + }) + if ( + fields.outcome !== 'resource-limited' || + fields.requestId !== request.requestId || + fields.expiresAt !== request.notAfter + ) + invalid() + return Object.freeze({ + version: 1, + outcome: 'resource-limited', + requestId: request.requestId, + expiresAt: request.notAfter + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts index d9f2568ef..54f9d2c52 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts @@ -3,7 +3,8 @@ import { createHash } from 'node:crypto' import { parseSnapshotArchiveRpcInput, snapshotArchiveMethods, - validateSnapshotArchiveRequestReceipt + validateSnapshotArchiveRequestReceipt, + type SnapshotArchiveMethod } from './SnapshotArchiveProtocol' import { SnapshotArchiveTransport } from './SnapshotArchiveTransport' @@ -33,6 +34,15 @@ test('generated protocol requests preserve exact identity, deadlines and termina .update(JSON.stringify(['wallet-snapshot-request/1', 1, nonce, notAfter, maxBytes])) .digest('hex') const request = { version: 1 as const, nonce, notAfter, maxBytes, requestId } + const readerRequest = { + version: 2 as const, + nonce, + notAfter, + maxBytes, + requestId: createHash('sha256') + .update(JSON.stringify(['wallet-snapshot-reader-request/1', 2, nonce, notAfter, maxBytes])) + .digest('hex') + } const archiveId = createHash('sha256') .update('archive:' + nonce) .digest('hex') @@ -51,16 +61,18 @@ test('generated protocol requests preserve exact identity, deadlines and termina expect(await transport.start(request)).toEqual(receipt) expect(await transport.status(request)).toEqual(receipt) expect(rpc.mock.calls[0]).toEqual(['startSnapshotArchive', [{ version: 1, identityKey, request }], undefined]) - const extra = - method === 'startSnapshotArchive' - ? { request } - : method === 'getSnapshotArchiveStatus' || method === 'cancelSnapshotArchive' - ? { requestId } - : method === 'getSnapshotArchiveDirectory' - ? { archiveId } - : method === 'readSnapshotArchivePage' - ? { archiveId, sequence: duration % 4096 } - : {} + const extras: Record = { + getSnapshotArchiveReaderOffer: { options: { lifetimeMs: duration, maxBytes } }, + admitSnapshotArchive: { request: readerRequest }, + cancelSnapshotArchiveRequest: { request: readerRequest }, + getSnapshotArchiveOffer: {}, + startSnapshotArchive: { request }, + getSnapshotArchiveStatus: { requestId }, + cancelSnapshotArchive: { requestId }, + getSnapshotArchiveDirectory: { archiveId }, + readSnapshotArchivePage: { archiveId, sequence: duration % 4096 } + } + const extra = extras[method] const input = { version: 1, identityKey, ...extra } expect(parseSnapshotArchiveRpcInput(method, [input])).toEqual({ method, identityKey, ...extra }) expect(() => parseSnapshotArchiveRpcInput(method, [{ ...input, claimToken: nonce }])).toThrow() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.test.ts index 3c994ead5..176217e31 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.test.ts @@ -5,9 +5,11 @@ import { parseSnapshotArchiveRpcInput, validateSnapshotArchiveCapabilities, validateSnapshotArchiveOffer, - validateSnapshotArchiveRequestReceipt + validateSnapshotArchiveRequestReceipt, + type SnapshotArchiveMethod } from './SnapshotArchiveProtocol' import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { snapshotArchiveReaderRequestId } from './SnapshotArchiveReaderRequest' import { verifySnapshotArchiveDirectory } from './SnapshotArchiveDirectory' import { stringifyJsonRpc } from '../../remoting/BinaryJson' import { fixture, expected, now, rehash } from '../../../../test/utils/snapshotArchiveDirectoryFixtures' @@ -136,17 +138,21 @@ test.each(['archiveId', 'digest'])('ready receipt requires a canonical %s', fiel }) test('every RPC has exact typed arguments and no ownership-token parameter', () => { + const readerFields = { ...fields, version: 2 as const } + const readerRequest = { ...readerFields, requestId: snapshotArchiveReaderRequestId(readerFields) } + const extras: Record> = { + getSnapshotArchiveOffer: {}, + startSnapshotArchive: { request }, + getSnapshotArchiveStatus: { requestId: request.requestId }, + cancelSnapshotArchive: { requestId: request.requestId }, + getSnapshotArchiveDirectory: { archiveId: 'b'.repeat(64) }, + readSnapshotArchivePage: { archiveId: 'b'.repeat(64), sequence: 0 }, + getSnapshotArchiveReaderOffer: { options: { lifetimeMs: 300000, maxBytes: 32768 } }, + admitSnapshotArchive: { request: readerRequest }, + cancelSnapshotArchiveRequest: { request: readerRequest } + } for (const method of snapshotArchiveMethods) { - const extra = - method === 'startSnapshotArchive' - ? { request } - : method === 'getSnapshotArchiveStatus' || method === 'cancelSnapshotArchive' - ? { requestId: request.requestId } - : method === 'getSnapshotArchiveDirectory' - ? { archiveId: 'b'.repeat(64) } - : method === 'readSnapshotArchivePage' - ? { archiveId: 'b'.repeat(64), sequence: 0 } - : {} + const extra = extras[method] const input = { version: 1, identityKey, ...extra } expect(parseSnapshotArchiveRpcInput(method, [input])).toEqual({ method, identityKey, ...extra }) for (const params of [ @@ -163,6 +169,17 @@ test('every RPC has exact typed arguments and no ownership-token parameter', () } }) +test('strong reader methods and legacy admission reject each other’s request version', () => { + const readerFields = { ...fields, version: 2 as const } + const readerRequest = { ...readerFields, requestId: snapshotArchiveReaderRequestId(readerFields) } + expect(() => + parseSnapshotArchiveRpcInput('startSnapshotArchive', [{ version: 1, identityKey, request: readerRequest }]) + ).toThrow() + for (const method of ['admitSnapshotArchive', 'cancelSnapshotArchiveRequest'] as const) { + expect(() => parseSnapshotArchiveRpcInput(method, [{ version: 1, identityKey, request }])).toThrow() + } +}) + test.each([-1, 0.1, 4096, Infinity, NaN, '0', null])('rejects page sequence %p before dispatch', sequence => { expect(() => parseSnapshotArchiveRpcInput('readSnapshotArchivePage', [ diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts index 5de0b9412..51e7c3519 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.ts @@ -1,3 +1,9 @@ +import { + validateSnapshotArchiveReaderOptions, + type SnapshotArchiveReaderOptions, + parseSnapshotArchiveReaderRequest, + type SnapshotArchiveReaderRequest +} from './SnapshotArchiveReaderRequest' import { snapshotArchiveLimits } from './SnapshotArchive' import { snapshotArchiveEncoding } from './SnapshotArchiveDirectory' import { @@ -26,7 +32,10 @@ export const snapshotArchiveMethods = Object.freeze([ 'getSnapshotArchiveStatus', 'getSnapshotArchiveDirectory', 'readSnapshotArchivePage', - 'cancelSnapshotArchive' + 'cancelSnapshotArchive', + 'getSnapshotArchiveReaderOffer', + 'admitSnapshotArchive', + 'cancelSnapshotArchiveRequest' ] as const) export type SnapshotArchiveMethod = (typeof snapshotArchiveMethods)[number] @@ -73,6 +82,12 @@ export function validateSnapshotArchiveCapabilities(input: unknown): SnapshotArc } export type SnapshotArchiveRpcInput = + | { method: 'getSnapshotArchiveReaderOffer'; identityKey: string; options: Readonly } + | { + method: 'admitSnapshotArchive' | 'cancelSnapshotArchiveRequest' + identityKey: string + request: Readonly + } | { method: 'getSnapshotArchiveOffer'; identityKey: string } | { method: 'startSnapshotArchive'; identityKey: string; request: Readonly } | { method: 'getSnapshotArchiveStatus' | 'cancelSnapshotArchive'; identityKey: string; requestId: string } @@ -81,6 +96,10 @@ export type SnapshotArchiveRpcInput = function requestFields(method: SnapshotArchiveMethod): string[] { switch (method) { + case 'getSnapshotArchiveReaderOffer': + return ['options'] + case 'admitSnapshotArchive': + case 'cancelSnapshotArchiveRequest': case 'startSnapshotArchive': return ['request'] case 'getSnapshotArchiveStatus': @@ -106,6 +125,11 @@ export function parseSnapshotArchiveRpcInput( invalid() const identityKey = input.identityKey switch (method) { + case 'getSnapshotArchiveReaderOffer': + return { method, identityKey, options: validateSnapshotArchiveReaderOptions(input.options) } + case 'admitSnapshotArchive': + case 'cancelSnapshotArchiveRequest': + return { method, identityKey, request: parseSnapshotArchiveReaderRequest(input.request) } case 'getSnapshotArchiveOffer': return { method, identityKey } case 'startSnapshotArchive': @@ -151,7 +175,7 @@ export function validateSnapshotArchiveOffer( export function validateSnapshotArchiveRequestReceipt( input: unknown, - request: SnapshotArchiveRequest + request: Pick ): Readonly { const state = input !== null && typeof input === 'object' ? Object.getOwnPropertyDescriptor(input, 'state')?.value : undefined diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderClient.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderClient.test.ts new file mode 100644 index 000000000..b52d24e2d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderClient.test.ts @@ -0,0 +1,113 @@ +import type { WalletInterface } from '@bsv/sdk' +import { StorageClientBase, type StorageClientOptions } from '../../remoting/StorageClientBase' +import type { WalletReadSnapshot } from '../WalletReadSnapshot' +import { snapshotArchiveCapabilities } from './SnapshotArchiveProtocol' +import * as RemoteReader from './openRemoteSnapshot' +import { SnapshotCancelledError } from '../SnapshotCancelledError' + +const identityKey = '02' + '11'.repeat(32) +class ReaderClient extends StorageClientBase { + readonly request = jest.fn, [string, unknown[]]>() + constructor(options: StorageClientOptions = {}) { + super({} as WalletInterface, 'https://storage.example.test', options) + } + protected async rpcCall(method: string, params: unknown[]): Promise { + return (await this.request(method, params)) as T + } +} +const settings = () => ({ + storageIdentityKey: 'reader-source', + chain: 'test', + snapshotArchive: { ...snapshotArchiveCapabilities }, + snapshotArchiveReaderVersion: 1 +}) +afterEach(() => jest.restoreAllMocks()) + +test('a source adapter exists before availability and negotiates the first open with detached capability state', async () => { + const client = new ReaderClient() + client.request.mockResolvedValue(settings()) + const source = client.getSnapshotSync()! + expect(client.request).not.toHaveBeenCalled() + expect(source.fallbackOnResourceError).toBe(false) + expect(await source.supportsDestination()).toBe(false) + const view = {} as WalletReadSnapshot + const opening = jest.spyOn(RemoteReader, 'openRemoteSnapshot').mockResolvedValue(view) + const options = { lifetimeMs: 1234, signal: new AbortController().signal } + expect(await source.openSource(identityKey, options)).toBe(view) + expect(client.request.mock.calls).toEqual([['makeAvailable', []]]) + expect(opening).toHaveBeenCalledWith(expect.objectContaining({ supportsReader: true }), options) + client.settings!.snapshotArchiveReaderVersion = undefined + expect((await client.getSnapshotArchiveTransport(identityKey))!.supportsReader).toBe(true) + for (const operation of [ + source.begin(undefined as never, undefined), + source.checkpoint('', ''), + source.prepare(undefined as never, undefined as never) + ]) + await expect(operation).rejects.toThrow('Remote snapshot destination is unavailable') + expect(client.request).toHaveBeenCalledTimes(1) +}) + +test.each(['legacy', 'archive-only'] as const)( + '%s peers decline before any reader request and cannot be upgraded by caller mutation', + async variant => { + const client = new ReaderClient() + const advertised = settings() + Reflect.deleteProperty(advertised, 'snapshotArchiveReaderVersion') + if (variant === 'legacy') Reflect.deleteProperty(advertised, 'snapshotArchive') + client.request.mockResolvedValue(advertised) + expect(await client.getSnapshotSync()!.openSource(identityKey)).toBeUndefined() + client.settings!.snapshotArchiveReaderVersion = 1 + const transport = await client.getSnapshotArchiveTransport(identityKey) + expect(transport?.supportsReader).not.toBe(true) + expect(client.request.mock.calls).toEqual([['makeAvailable', []]]) + } +) + +test('the existing client rollback option disables the source adapter before network I/O', () => { + const client = new ReaderClient({ snapshotArchives: false }) + expect(client.getSnapshotSync()).toBeUndefined() + expect(client.request).not.toHaveBeenCalled() +}) + +test.each([undefined, null, 0, 2, '1', true])( + 'an invalid reader advertisement %p rejects availability', + async version => { + const client = new ReaderClient() + client.request.mockResolvedValue({ ...settings(), snapshotArchiveReaderVersion: version }) + await expect(client.getSnapshotSync()!.openSource(identityKey)).rejects.toThrow('invalid settings') + expect(client.isAvailable()).toBe(false) + } +) + +test('reader advertisement requires the original archive capability', async () => { + const client = new ReaderClient() + const advertised = settings() + Reflect.deleteProperty(advertised, 'snapshotArchive') + client.request.mockResolvedValue(advertised) + await expect(client.makeAvailable()).rejects.toThrow('invalid settings') +}) + +test.each(['cancelled', 'live-signal', 'ordinary', 'inherited-code', 'accessor-code'] as const)( + 'authenticated transport %s classification preserves errors outside explicit local cancellation', + async variant => { + const client = new ReaderClient() + client.request.mockResolvedValue(settings()) + const transport = (await client.getSnapshotArchiveTransport(identityKey))! + const controller = new AbortController() + const failure = new Error('synthetic authenticated transport refusal') + const getter = jest.fn(() => 'ERR_PAYMENT_CANCELLED') + if (variant === 'cancelled' || variant === 'live-signal') + Object.defineProperty(failure, 'code', { value: 'ERR_PAYMENT_CANCELLED' }) + if (variant === 'inherited-code') + Object.setPrototypeOf(failure, Object.create(Error.prototype, { code: { value: 'ERR_PAYMENT_CANCELLED' } })) + if (variant === 'accessor-code') Object.defineProperty(failure, 'code', { get: getter }) + if (variant !== 'live-signal') controller.abort() + const fetch = jest.fn().mockRejectedValue(failure) + Reflect.set(client, 'snapshotAuthClient', { fetch }) + const opening = transport.readerOffer({ lifetimeMs: 300000, maxBytes: 32768 }, controller.signal) + if (variant === 'cancelled') await expect(opening).rejects.toBeInstanceOf(SnapshotCancelledError) + else await expect(opening).rejects.toBe(failure) + expect(fetch).toHaveBeenCalledTimes(1) + expect(getter).not.toHaveBeenCalled() + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderOffer.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderOffer.test.ts new file mode 100644 index 000000000..508b18635 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderOffer.test.ts @@ -0,0 +1,129 @@ +import { snapshotArchiveLimits } from './SnapshotArchive' +import { snapshotArchiveReaderRequestId } from './SnapshotArchiveReaderRequest' +import { validateSnapshotArchiveReaderOffer, validateSnapshotArchiveReaderOptions } from './SnapshotArchiveReaderOffer' + +const options = { lifetimeMs: 300000, maxBytes: 32768 } +const storage = 'original-storage' +function fixture() { + const fields = { version: 2 as const, nonce: 'a'.repeat(64), notAfter: 1300000, maxBytes: 32768 } + return { + version: 1, + outcome: 'offered', + offer: { + version: 1, + serverTime: 1000000, + sourceStorageIdentityKey: storage, + sourceSchema: 'original-schema', + chain: 'test' + }, + request: { ...fields, requestId: snapshotArchiveReaderRequestId(fields) } + } +} +const parse = (value: unknown) => validateSnapshotArchiveReaderOffer(value, options, storage, 'test') + +test('reader offer retains exactly the authenticated options, source and immutable request', () => { + const input = fixture() + const output = parse(input) + expect(output).toEqual(input) + expect(output).not.toBe(input) + if (output.outcome !== 'offered') throw new Error('Expected offered fixture') + expect(output.request).not.toBe(input.request) + expect(output.offer).not.toBe(input.offer) + input.offer.sourceSchema = 'later caller mutation' + input.request.nonce = 'b'.repeat(64) + expect(output.offer.sourceSchema).toBe('original-schema') + expect(output.request.nonce).toBe('a'.repeat(64)) + expect(Object.isFrozen(output)).toBe(true) + expect(Object.isFrozen(output.offer)).toBe(true) + expect(Object.isFrozen(output.request)).toBe(true) + expect(parse({ version: 1, outcome: 'resource-limited' })).toEqual({ version: 1, outcome: 'resource-limited' }) +}) + +test('a refusal carries no request that could have been admitted', () => { + for (const name of ['request', 'offer', 'requestId', 'expiresAt', 'claimToken']) { + expect(() => parse({ version: 1, outcome: 'resource-limited', [name]: fixture().request })).toThrow( + 'Invalid snapshot archive reader offer' + ) + } + for (const outcome of ['accepted', 'building', 'ready', undefined, null, 1]) { + expect(() => parse({ version: 1, outcome })).toThrow('Invalid snapshot archive reader offer') + } +}) + +test('offer framing rejects altered versions, hidden fields and accessors without evaluating them', () => { + for (const input of [null, [], 'offered', 1, {}, { ...fixture(), version: 2 }, { ...fixture(), extra: true }]) { + expect(() => parse(input)).toThrow('Invalid snapshot archive reader offer') + } + for (const name of ['version', 'outcome', 'offer', 'request']) { + const getter = jest.fn(() => { + throw new Error('must not evaluate property') + }) + const input = fixture() + Object.defineProperty(input, name, { get: getter, enumerable: true }) + expect(() => parse(input)).toThrow('Invalid snapshot archive reader offer') + expect(getter).not.toHaveBeenCalled() + const hidden = fixture() + Object.defineProperty(hidden, name, { value: hidden[name as keyof typeof hidden], enumerable: false }) + expect(() => parse(hidden)).toThrow('Invalid snapshot archive reader offer') + const missing = { ...fixture() } as Record + delete missing[name] + expect(() => parse(missing)).toThrow('Invalid snapshot archive reader offer') + } + const symbol = fixture() + Object.defineProperty(symbol, Symbol('extra'), { value: true }) + expect(() => parse(symbol)).toThrow('Invalid snapshot archive reader offer') +}) + +test('valid request hashes cannot change negotiated time, budget, source schema shape or chain', () => { + for (const change of [{ notAfter: 1300001 }, { notAfter: 1299999 }, { maxBytes: 32769 }, { maxBytes: 32767 }]) { + const input = fixture() + Object.assign(input.request, change) + input.request.requestId = snapshotArchiveReaderRequestId(input.request) + expect(() => parse(input)).toThrow() + } + for (const change of [ + { serverTime: 999999 }, + { sourceStorageIdentityKey: 'substituted' }, + { sourceSchema: '' }, + { chain: 'main' } + ]) { + const input = fixture() + Object.assign(input.offer, change) + expect(() => parse(input)).toThrow() + } + const changedHash = fixture() + changedHash.request.requestId = 'b'.repeat(64) + expect(() => parse(changedHash)).toThrow() +}) + +test('reader options admit exact integer bounds and reject unbounded or getter-backed input', () => { + const minimum = { lifetimeMs: 1, maxBytes: snapshotArchiveLimits.headerCharge + 1 } + const maximum = { lifetimeMs: snapshotArchiveLimits.lifetimeMs, maxBytes: snapshotArchiveLimits.archiveBytes } + expect(validateSnapshotArchiveReaderOptions(minimum)).toEqual(minimum) + expect(validateSnapshotArchiveReaderOptions(maximum)).toEqual(maximum) + for (const lifetimeMs of [0, -1, 1.5, Number.NaN, Infinity, snapshotArchiveLimits.lifetimeMs + 1, '1']) { + expect(() => validateSnapshotArchiveReaderOptions({ ...options, lifetimeMs })).toThrow( + 'Invalid snapshot archive reader request' + ) + } + for (const maxBytes of [ + snapshotArchiveLimits.headerCharge, + snapshotArchiveLimits.archiveBytes + 1, + 1.5, + Number.NaN, + Infinity, + '32768' + ]) { + expect(() => validateSnapshotArchiveReaderOptions({ ...options, maxBytes })).toThrow( + 'Invalid snapshot archive reader request' + ) + } + for (const input of [null, [], {}, { lifetimeMs: 1 }, { maxBytes: 32768 }, { ...options, extra: true }]) { + expect(() => validateSnapshotArchiveReaderOptions(input)).toThrow('Invalid snapshot archive reader request') + } + const getter = jest.fn(() => 1) + expect(() => + validateSnapshotArchiveReaderOptions(Object.defineProperty({ ...options }, 'lifetimeMs', { get: getter })) + ).toThrow('Invalid snapshot archive reader request') + expect(getter).not.toHaveBeenCalled() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderOffer.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderOffer.ts new file mode 100644 index 000000000..94b8c827e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderOffer.ts @@ -0,0 +1,56 @@ +import { validateSnapshotArchiveOffer, type SnapshotArchiveOffer } from './SnapshotArchiveProtocol' +import { + validateSnapshotArchiveReaderRequest, + validateSnapshotArchiveReaderOptions, + type SnapshotArchiveReaderRequest, + type SnapshotArchiveReaderOptions +} from './SnapshotArchiveReaderRequest' +export { validateSnapshotArchiveReaderOptions, type SnapshotArchiveReaderOptions } from './SnapshotArchiveReaderRequest' + +/** A refused offer has no request and cannot have started a capture. */ +export type SnapshotArchiveReaderOffer = + | { + version: 1 + outcome: 'offered' + offer: Readonly + request: Readonly + } + | { version: 1; outcome: 'resource-limited' } + +function invalid(): never { + throw new TypeError('Invalid snapshot archive reader offer') +} + +function record(input: unknown, names: readonly string[]): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) invalid() + if (Reflect.ownKeys(input).length !== names.length) invalid() + const result: Record = {} + for (const name of names) { + const field = Object.getOwnPropertyDescriptor(input, name) + if (field === undefined || !('value' in field) || !field.enumerable) invalid() + result[name] = field.value + } + return result +} + +export function validateSnapshotArchiveReaderOffer( + input: unknown, + options: SnapshotArchiveReaderOptions, + storageIdentityKey: string, + chain: 'main' | 'test' +): Readonly { + const expected = validateSnapshotArchiveReaderOptions(options) + const outcome = + input !== null && typeof input === 'object' ? Object.getOwnPropertyDescriptor(input, 'outcome')?.value : undefined + const fields = record( + input, + outcome === 'offered' ? ['version', 'outcome', 'offer', 'request'] : ['version', 'outcome'] + ) + if (fields.version !== 1) invalid() + if (fields.outcome === 'resource-limited') return Object.freeze({ version: 1, outcome: 'resource-limited' }) + if (fields.outcome !== 'offered') invalid() + const offer = validateSnapshotArchiveOffer(fields.offer, storageIdentityKey, chain) + const request = validateSnapshotArchiveReaderRequest(fields.request, offer.serverTime) + if (request.notAfter !== offer.serverTime + expected.lifetimeMs || request.maxBytes !== expected.maxBytes) invalid() + return Object.freeze({ version: 1, outcome: 'offered', offer, request }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderRequest.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderRequest.test.ts new file mode 100644 index 000000000..97ab6120e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderRequest.test.ts @@ -0,0 +1,134 @@ +import { createHash } from 'node:crypto' +import { parseSnapshotArchiveRequest, snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { + parseSnapshotArchiveReaderRequest, + snapshotArchiveReaderRequestId, + validateSnapshotArchiveReaderRequest +} from './SnapshotArchiveReaderRequest' +import { snapshotArchiveLimits } from './SnapshotArchive' + +const fields = { version: 2 as const, nonce: 'a'.repeat(64), notAfter: 1300000, maxBytes: 32768 } +function fixture() { + return { ...fields, requestId: snapshotArchiveReaderRequestId(fields) } +} + +test('independent SHA-256 fixes the reader domain and both parser paths reject cross-protocol requests', () => { + const expected = createHash('sha256') + .update(JSON.stringify(['wallet-snapshot-reader-request/1', 2, 'a'.repeat(64), 1300000, 32768])) + .digest('hex') + expect(snapshotArchiveReaderRequestId(fields)).toBe(expected) + const reader = fixture() + expect(parseSnapshotArchiveReaderRequest(reader)).toEqual(reader) + expect(() => parseSnapshotArchiveRequest(reader)).toThrow() + expect(() => parseSnapshotArchiveRequest({ ...reader, version: 1 })).toThrow() + const legacyFields = { ...fields, version: 1 as const } + const legacy = { ...legacyFields, requestId: snapshotArchiveRequestId(legacyFields) } + expect(legacy.requestId).not.toBe(reader.requestId) + expect(parseSnapshotArchiveRequest(legacy)).toEqual(legacy) + expect(() => parseSnapshotArchiveReaderRequest(legacy)).toThrow() + expect(() => parseSnapshotArchiveReaderRequest({ ...legacy, version: 2 })).toThrow() +}) + +test('reader request parsing detaches every field before asynchronous admission', () => { + const input = fixture() + const parsed = parseSnapshotArchiveReaderRequest(input) + input.nonce = 'b'.repeat(64) + input.notAfter++ + input.maxBytes++ + input.requestId = 'b'.repeat(64) + expect(parsed).toEqual(fixture()) + expect(Object.isFrozen(parsed)).toBe(true) +}) + +test('reader request exact fields reject altered identities, invalid bounds and hidden/getter values', () => { + const changes = [ + { version: 1 }, + { nonce: 'a'.repeat(63) }, + { nonce: 'A'.repeat(64) }, + { nonce: 1 }, + { notAfter: 0 }, + { notAfter: 1.5 }, + { notAfter: Number.MAX_SAFE_INTEGER + 1 }, + { notAfter: '1300000' }, + { maxBytes: snapshotArchiveLimits.headerCharge }, + { maxBytes: snapshotArchiveLimits.archiveBytes + 1 }, + { maxBytes: Number.NaN }, + { maxBytes: '32768' }, + { requestId: 'b'.repeat(64) }, + { extra: true } + ] + for (const change of changes) + expect(() => parseSnapshotArchiveReaderRequest({ ...fixture(), ...change })).toThrow( + 'Invalid snapshot archive reader request' + ) + for (const input of [undefined, null, 1, [], {}, 'request']) + expect(() => parseSnapshotArchiveReaderRequest(input)).toThrow('Invalid snapshot archive reader request') + for (const key of Object.keys(fixture())) { + const missing = { ...fixture() } as Record + delete missing[key] + expect(() => parseSnapshotArchiveReaderRequest(missing)).toThrow('Invalid snapshot archive reader request') + const getter = jest.fn(() => 1) + expect(() => + parseSnapshotArchiveReaderRequest(Object.defineProperty(fixture(), key, { get: getter, enumerable: true })) + ).toThrow('Invalid snapshot archive reader request') + expect(getter).not.toHaveBeenCalled() + const hidden = fixture() + Object.defineProperty(hidden, key, { enumerable: false }) + expect(() => parseSnapshotArchiveReaderRequest(hidden)).toThrow('Invalid snapshot archive reader request') + } +}) + +test('database-clock deadline validation never extends a reader after expiry', () => { + expect(validateSnapshotArchiveReaderRequest(fixture(), 1000000)).toEqual(fixture()) + expect(validateSnapshotArchiveReaderRequest(fixture(), fields.notAfter - 1)).toEqual(fixture()) + for (const now of [fields.notAfter, fields.notAfter + 1, -1, Number.NaN, Infinity, 1.5]) { + expect(() => validateSnapshotArchiveReaderRequest(fixture(), now)).toThrow( + 'Invalid snapshot archive reader request' + ) + } + const long = { ...fields, notAfter: 1000000 + snapshotArchiveLimits.lifetimeMs } + expect( + validateSnapshotArchiveReaderRequest({ ...long, requestId: snapshotArchiveReaderRequestId(long) }, 1000000).notAfter + ).toBe(long.notAfter) + long.notAfter++ + expect(() => + validateSnapshotArchiveReaderRequest({ ...long, requestId: snapshotArchiveReaderRequestId(long) }, 1000000) + ).toThrow('Invalid snapshot archive reader request') +}) + +test('valid independent digests do not authorize malformed request fields or exclude inclusive bounds', () => { + const signed = (changes: Record) => { + const value = { ...fields, ...changes } + const requestId = createHash('sha256') + .update( + JSON.stringify(['wallet-snapshot-reader-request/1', value.version, value.nonce, value.notAfter, value.maxBytes]) + ) + .digest('hex') + return { ...value, requestId } + } + for (const change of [ + { nonce: 'x' + fields.nonce }, + { nonce: fields.nonce + 'x' }, + { nonce: fields.nonce + '\n' }, + { notAfter: 0 }, + { notAfter: -1 }, + { notAfter: 1.5 }, + { notAfter: '1300000' }, + { maxBytes: snapshotArchiveLimits.headerCharge }, + { maxBytes: snapshotArchiveLimits.headerCharge - 1 }, + { maxBytes: snapshotArchiveLimits.archiveBytes + 1 }, + { maxBytes: '32768' } + ]) { + expect(() => parseSnapshotArchiveReaderRequest(signed(change))).toThrow('Invalid snapshot archive reader request') + } + for (const maxBytes of [snapshotArchiveLimits.headerCharge + 1, snapshotArchiveLimits.archiveBytes]) { + const value = signed({ notAfter: 1, maxBytes }) + expect(parseSnapshotArchiveReaderRequest(value)).toEqual(value) + expect(validateSnapshotArchiveReaderRequest(value, 0)).toEqual(value) + } + for (const name of Object.keys(fixture())) { + const missing: Record = { ...fixture(), replacement: 1 } + delete missing[name] + expect(() => parseSnapshotArchiveReaderRequest(missing)).toThrow('Invalid snapshot archive reader request') + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderRequest.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderRequest.ts new file mode 100644 index 000000000..d20d3d136 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderRequest.ts @@ -0,0 +1,119 @@ +import { syncTransferDigest } from '../../remoting/SyncTransfer' +import { snapshotArchiveLimits } from './SnapshotArchive' + +/** Server-issued reader admission only. An absent request can never create a capture. */ +export interface SnapshotArchiveReaderRequest { + version: 2 + nonce: string + notAfter: number + maxBytes: number + requestId: string +} + +function invalid(): never { + throw new TypeError('Invalid snapshot archive reader request') +} + +export interface SnapshotArchiveReaderOptions { + lifetimeMs: number + maxBytes: number +} + +function record(input: unknown, names: readonly string[]): Record { + if ( + input === null || + typeof input !== 'object' || + Array.isArray(input) || + Reflect.ownKeys(input).length !== names.length + ) + invalid() + const fields: Record = {} + for (const name of names) { + const property = Object.getOwnPropertyDescriptor(input, name) + if (property === undefined || !('value' in property) || !property.enumerable) invalid() + fields[name] = property.value + } + return fields +} + +export function validateSnapshotArchiveReaderOptions(input: unknown): Readonly { + const fields = record(input, ['lifetimeMs', 'maxBytes']) + const { lifetimeMs, maxBytes } = fields + if ( + typeof lifetimeMs !== 'number' || + !Number.isSafeInteger(lifetimeMs) || + lifetimeMs < 1 || + lifetimeMs > snapshotArchiveLimits.lifetimeMs || + typeof maxBytes !== 'number' || + !Number.isSafeInteger(maxBytes) || + maxBytes < snapshotArchiveLimits.headerCharge + 1 || + maxBytes > snapshotArchiveLimits.archiveBytes + ) + invalid() + return Object.freeze({ lifetimeMs, maxBytes }) +} + +export function snapshotArchiveReaderRequestId(request: Omit): string { + return syncTransferDigest( + new TextEncoder().encode( + JSON.stringify([ + 'wallet-snapshot-reader-request/1', + request.version, + request.nonce, + request.notAfter, + request.maxBytes + ]) + ) + ) +} + +/** The distinct version and digest domain prevent the legacy start API recreating a closed reader. */ +export function parseSnapshotArchiveReaderRequest(input: unknown): Readonly { + const names = ['version', 'nonce', 'notAfter', 'maxBytes', 'requestId'] + if ( + input === null || + typeof input !== 'object' || + Array.isArray(input) || + Reflect.ownKeys(input).length !== names.length + ) + invalid() + const values: Record = {} + for (const name of names) { + const field = Object.getOwnPropertyDescriptor(input, name) + if (field === undefined || !('value' in field) || !field.enumerable) invalid() + values[name] = field.value + } + const { nonce, notAfter, maxBytes } = values + if ( + values.version !== 2 || + typeof nonce !== 'string' || + !/^[0-9a-f]{64}$/.test(nonce) || + typeof notAfter !== 'number' || + !Number.isSafeInteger(notAfter) || + notAfter < 1 || + typeof maxBytes !== 'number' || + !Number.isSafeInteger(maxBytes) || + maxBytes < snapshotArchiveLimits.headerCharge + 1 || + maxBytes > snapshotArchiveLimits.archiveBytes + ) + invalid() + const fields = { version: 2 as const, nonce, notAfter, maxBytes } + const requestId = snapshotArchiveReaderRequestId(fields) + if (values.requestId !== requestId) invalid() + return Object.freeze({ ...fields, requestId }) +} + +export function validateSnapshotArchiveReaderRequest( + input: unknown, + now: number +): Readonly { + const request = parseSnapshotArchiveReaderRequest(input) + if ( + !Number.isSafeInteger(now) || + now < 0 || + request.notAfter <= now || + request.notAfter - now > snapshotArchiveLimits.lifetimeMs + ) + invalid() + return request +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts index fe949823a..47792d06b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts @@ -1,3 +1,11 @@ +import { + validateSnapshotArchiveReaderOffer, + validateSnapshotArchiveReaderOptions, + type SnapshotArchiveReaderOptions, + type SnapshotArchiveReaderOffer +} from './SnapshotArchiveReaderOffer' +import { parseSnapshotArchiveReaderRequest, type SnapshotArchiveReaderRequest } from './SnapshotArchiveReaderRequest' +import { validateSnapshotArchiveAdmission, type SnapshotArchiveAdmission } from './SnapshotArchiveAdmission' import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage, @@ -28,7 +36,8 @@ export class SnapshotArchiveTransport { private readonly rpc: SnapshotArchiveRpcCall, private readonly identityKey: string, private readonly sourceStorageIdentityKey: string, - private readonly chain: 'main' | 'test' + private readonly chain: 'main' | 'test', + readonly supportsReader = false ) { parseSnapshotArchiveRpcInput('getSnapshotArchiveOffer', [{ version: 1, identityKey }]) } @@ -64,6 +73,49 @@ export class SnapshotArchiveTransport { ) } + private requireReader(): void { + if (!this.supportsReader) throw new TypeError('Snapshot archive reader was not negotiated') + } + + async readerOffer( + input: SnapshotArchiveReaderOptions, + signal?: AbortSignal + ): Promise> { + this.requireReader() + const options = validateSnapshotArchiveReaderOptions(input) + return validateSnapshotArchiveReaderOffer( + await this.call('getSnapshotArchiveReaderOffer', { options }, signal), + options, + this.sourceStorageIdentityKey, + this.chain + ) + } + + async admit(input: SnapshotArchiveReaderRequest, signal?: AbortSignal): Promise> { + this.requireReader() + const request = parseSnapshotArchiveReaderRequest(input) + return validateSnapshotArchiveAdmission(await this.call('admitSnapshotArchive', { request }, signal), request) + } + + async readerStatus( + input: SnapshotArchiveReaderRequest, + signal?: AbortSignal + ): Promise> { + this.requireReader() + const request = parseSnapshotArchiveReaderRequest(input) + return validateSnapshotArchiveRequestReceipt( + await this.call('getSnapshotArchiveStatus', { requestId: request.requestId }, signal), + request + ) + } + + async cancelRequest(input: SnapshotArchiveReaderRequest, signal?: AbortSignal): Promise { + this.requireReader() + const request = parseSnapshotArchiveReaderRequest(input) + if ((await this.call('cancelSnapshotArchiveRequest', { request }, signal)) !== true) + throw new TypeError('Invalid snapshot archive cancellation receipt') + } + async directory( receipt: Readonly, offer: Readonly, diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.test.ts new file mode 100644 index 000000000..d39edfeaa --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.test.ts @@ -0,0 +1,68 @@ +import { + isSnapshotArchiveTransportFailure, + snapshotArchiveFetch, + SnapshotArchiveTransportFailure +} from './SnapshotArchiveTransportFailure' + +test('the dedicated native-fetch wrapper forwards successful responses and original request arguments', async () => { + const response = new Response('body', { headers: { 'x-fixture': 'unchanged' } }) + const native = jest.fn(async () => response) + const fetch = snapshotArchiveFetch(native) + const options = { method: 'POST', body: 'request', signal: new AbortController().signal } + expect(await fetch('http://127.0.0.1:1', options)).toBe(response) + expect(native).toHaveBeenCalledWith('http://127.0.0.1:1', options) + expect(response.bodyUsed).toBe(false) +}) + +test('only an actual native-fetch rejection is marked, preserving its original cause', async () => { + const cause = new Error('synthetic connection loss') + const native = jest.fn(async () => { + throw cause + }) + const error = await snapshotArchiveFetch(native)('http://127.0.0.1:1').catch(error => error) + expect(error).toBeInstanceOf(SnapshotArchiveTransportFailure) + expect(error.cause).toBe(cause) + expect(error.name).toBe('SnapshotArchiveTransportFailure') + expect(error.message).toBe('Snapshot archive transport failed before a response was available') + expect(isSnapshotArchiveTransportFailure(error)).toBe(true) + const sdkWrapper = new Error('SDK network wrapper') + Object.defineProperty(sdkWrapper, 'cause', { value: error }) + expect(isSnapshotArchiveTransportFailure(sdkWrapper)).toBe(true) + const secondWrapper = new Error('An unrelated failure') + Object.defineProperty(secondWrapper, 'cause', { value: sdkWrapper }) + expect(isSnapshotArchiveTransportFailure(secondWrapper)).toBe(false) +}) + +test('auth, framing, body bounds and arbitrary error names never authorize recovery', async () => { + for (const message of [ + 'Invalid signature', + 'Authenticated response frame exceeds the configured limit.', + 'SnapshotArchiveTransportFailure' + ]) { + const error = new Error(message) + error.name = 'SnapshotArchiveTransportFailure' + expect(isSnapshotArchiveTransportFailure(error)).toBe(false) + } + for (const value of [undefined, null, 1, 'network error', { cause: new SnapshotArchiveTransportFailure('loss') }]) + expect(isSnapshotArchiveTransportFailure(value)).toBe(false) + const getter = new Error('Unrelated') + Object.defineProperty(getter, 'cause', { + get: () => { + throw new Error('Getter must not run') + } + }) + expect(isSnapshotArchiveTransportFailure(getter)).toBe(false) + const bodyFailure = new Error('synthetic body read failure') + const response = new Response( + new ReadableStream({ + start(controller) { + controller.error(bodyFailure) + } + }) + ) + const native = jest.fn(async () => response) + const fetched = await snapshotArchiveFetch(native)('http://127.0.0.1:1') + const error = await fetched.text().catch(error => error) + expect(error).toBe(bodyFailure) + expect(isSnapshotArchiveTransportFailure(error)).toBe(false) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.ts new file mode 100644 index 000000000..969ba721a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.ts @@ -0,0 +1,38 @@ +/** Marker only for rejection of the native fetch promise, before response handling. */ +export class SnapshotArchiveTransportFailure extends Error { + override readonly cause: unknown + constructor(cause: unknown) { + super('Snapshot archive transport failed before a response was available') + this.name = 'SnapshotArchiveTransportFailure' + this.cause = cause + } +} + +/** Inject only into this client's dedicated AuthFetch; never wrap AuthFetch.fetch itself. */ +export function snapshotArchiveFetch(implementation: typeof fetch): typeof fetch { + return async (input, init) => { + try { + return await implementation(input, init) + } catch (error) { + throw new SnapshotArchiveTransportFailure(error) + } + } +} + +/** The SDK transport preserves a native-fetch error as its direct own data cause. */ +export function isSnapshotArchiveTransportFailure(error: unknown): boolean { + if (error instanceof SnapshotArchiveTransportFailure) return true + if (!(error instanceof Error)) return false + const cause = Object.getOwnPropertyDescriptor(error, 'cause') + return cause !== undefined && 'value' in cause && cause.value instanceof SnapshotArchiveTransportFailure +} + +/** Only immutable, idempotent requests may repeat after native connection loss. */ +export async function retrySnapshotArchiveOperation(operation: () => Promise): Promise { + try { + return await operation() + } catch (error) { + if (!isSnapshotArchiveTransportFailure(error)) throw error + return await operation() + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/openRemoteSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/openRemoteSnapshot.ts new file mode 100644 index 000000000..7a1777884 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/openRemoteSnapshot.ts @@ -0,0 +1,98 @@ +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import type { WalletReadSnapshot, WalletReadSnapshotOptions } from '../WalletReadSnapshot' +import { snapshotArchiveLimits } from './SnapshotArchive' +import type { SnapshotArchiveRequestReceipt } from './SnapshotArchiveRequest' +import type { SnapshotArchiveReaderRequest } from './SnapshotArchiveReaderRequest' +import type { SnapshotArchiveTransport } from './SnapshotArchiveTransport' +import { RemoteSnapshotLease } from './RemoteSnapshotLease' +import { createRemoteSnapshotPageReader } from './RemoteSnapshotPageReader' + +/** Preserve both failures without depending on AggregateError in native runtimes. */ +export class RemoteSnapshotOpeningCleanupError extends Error { + constructor( + override readonly cause: unknown, + readonly cleanupError: unknown + ) { + super('Remote snapshot opening and cleanup failed') + this.name = 'RemoteSnapshotOpeningCleanupError' + } +} + +async function accepted( + transport: SnapshotArchiveTransport, + lease: RemoteSnapshotLease, + request: SnapshotArchiveReaderRequest +): Promise | undefined> { + // The exact durable tuple deduplicates admission; never renew or recapture. + const result = await lease.runIdempotent(signal => transport.admit(request, signal)) + return result.outcome === 'accepted' ? result.receipt : undefined +} + +/** Unsupported capacity may decline only before a source view is exposed. */ +export async function openRemoteSnapshot( + transport: SnapshotArchiveTransport, + options: WalletReadSnapshotOptions = {} +): Promise { + if (!transport.supportsReader) return undefined + const lease = new RemoteSnapshotLease(transport, options) + try { + const issued = await lease.run(signal => + transport.readerOffer({ lifetimeMs: lease.lifetimeMs, maxBytes: snapshotArchiveLimits.archiveBytes }, signal) + ) + if (issued.outcome === 'resource-limited') { + await lease.close() + return undefined + } + const { offer, request } = issued + lease.bindServerTime(offer.serverTime) + lease.own(request) + let receipt = await accepted(transport, lease, request) + if (receipt === undefined) { + await lease.close() + return undefined + } + let interval = 100 + while (receipt.state === 'building') { + await lease.wait(interval) + receipt = await lease.runIdempotent(signal => transport.readerStatus(request, signal)) + interval = Math.min(interval * 2, 1000) + } + if (receipt.state === 'resource-limited') { + await lease.close() + return undefined + } + if (receipt.state !== 'ready') throw new WERR_INVALID_OPERATION(`Snapshot archive capture is ${receipt.state}`) + const directory = await lease.runIdempotent(signal => transport.directory(receipt, offer, lease.now(), signal)) + if (lease.now() >= directory.manifest.expiresAt) throw new WERR_INVALID_OPERATION('Remote snapshot expired') + const binding = directory.manifest.binding + const copyDates = (value: T): T => ({ + ...value, + created_at: new Date(value.created_at.getTime()), + updated_at: new Date(value.updated_at.getTime()) + }) + return Object.freeze({ + version: 1 as const, + snapshotId: binding.snapshotId, + get sourceStorage() { + return copyDates(binding.sourceStorage) + }, + get user() { + return copyDates(binding.user) + }, + expiresAt: lease.expiresAt, + get isOpen() { + return lease.isOpen + }, + closed: lease.closed, + close: () => lease.close(), + readPage: createRemoteSnapshotPageReader(transport, directory, lease) + }) + } catch (error) { + try { + await lease.close() + } catch (cleanup) { + throw new RemoteSnapshotOpeningCleanupError(error, cleanup) + } + throw error + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts index 63625f934..86d51e939 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts @@ -5,6 +5,7 @@ import { SyncPageBudget } from '../sync/SyncPageBudget' import type { SyncSessionOptions, SyncSessionProgress, SyncSessionResult } from '../sync/syncSession' import { snapshotSyncTables, type SnapshotSyncCheckpoint, type SnapshotSyncStorage } from './SnapshotSync' import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' +import { copySnapshotCursor, sameSnapshotArchivePosition } from './SnapshotCursor' interface SnapshotSession { view: WalletReadSnapshot @@ -17,9 +18,7 @@ interface SnapshotSession { function copyCheckpoint(value: SnapshotSyncCheckpoint): SnapshotSyncCheckpoint function copyCheckpoint(value: SnapshotSyncCheckpoint | undefined): SnapshotSyncCheckpoint | undefined function copyCheckpoint(value: SnapshotSyncCheckpoint | undefined): SnapshotSyncCheckpoint | undefined { - return value === undefined - ? undefined - : { ...value, cursor: value.cursor === undefined ? undefined : { ...value.cursor, after: [...value.cursor.after] } } + return value === undefined ? undefined : { ...value, cursor: copySnapshotCursor(value.cursor) } } type NotifyProgress = (state: SyncSessionProgress['state'], timing?: Partial) => void @@ -53,7 +52,8 @@ function validateAcknowledgement( next.cursor?.version !== cursor?.version || next.cursor?.snapshotId !== cursor?.snapshotId || next.cursor?.table !== cursor?.table || - JSON.stringify(next.cursor?.after) !== JSON.stringify(cursor?.after) + JSON.stringify(next.cursor?.after) !== JSON.stringify(cursor?.after) || + !sameSnapshotArchivePosition(next.cursor?.archivePosition, cursor?.archivePosition) ) { throw new WERR_INVALID_OPERATION( 'Snapshot destination acknowledgement does not match the committed page; reload its durable checkpoint' diff --git a/packages/wallet/wallet-toolbox/src/utility/hashWasm.ts b/packages/wallet/wallet-toolbox/src/utility/hashWasm.ts index 8f793066a..f4771fe38 100644 --- a/packages/wallet/wallet-toolbox/src/utility/hashWasm.ts +++ b/packages/wallet/wallet-toolbox/src/utility/hashWasm.ts @@ -64,7 +64,7 @@ async function argon2idWithBackends(options: HashWasmArgon2idOptions): Promise = 0 extends 1 & T ? true : false +const hasImplicitAny: IsAny = false +const original: typeof import('hash-wasm').argon2id = hashWasm.argon2id +const binary: Promise = hashWasm.argon2id({ + password: new Uint8Array([1]), + salt: new Uint8Array(16), + iterations: 1, + parallelism: 1, + memorySize: 8, + hashLength: 32, + outputType: 'binary' +}) +const encoded: Promise = hashWasm.argon2id({ + password: 'synthetic', + salt: 'synthetic-salt', + iterations: 1, + parallelism: 1, + memorySize: 8, + hashLength: 32, + outputType: 'encoded' +}) +void hasImplicitAny +void original +void binary +void encoded diff --git a/packages/wallet/wallet-toolbox/test/consumer/hashWasm.mts b/packages/wallet/wallet-toolbox/test/consumer/hashWasm.mts new file mode 100644 index 000000000..545423e03 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/consumer/hashWasm.mts @@ -0,0 +1,28 @@ +// Check the advertised CommonJS leaf from both CommonJS and ESM consumers. +import hashWasm = require('@bsv/wallet-toolbox/out/src/utility/hashWasm') + +type IsAny = 0 extends 1 & T ? true : false +const hasImplicitAny: IsAny = false +const original: typeof import('hash-wasm').argon2id = hashWasm.argon2id +const binary: Promise = hashWasm.argon2id({ + password: new Uint8Array([1]), + salt: new Uint8Array(16), + iterations: 1, + parallelism: 1, + memorySize: 8, + hashLength: 32, + outputType: 'binary' +}) +const encoded: Promise = hashWasm.argon2id({ + password: 'synthetic', + salt: 'synthetic-salt', + iterations: 1, + parallelism: 1, + memorySize: 8, + hashLength: 32, + outputType: 'encoded' +}) +void hasImplicitAny +void original +void binary +void encoded diff --git a/packages/wallet/wallet-toolbox/test/consumer/tsconfig.json b/packages/wallet/wallet-toolbox/test/consumer/tsconfig.json new file mode 100644 index 000000000..fd19be7a4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/consumer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../../config/typescript/test.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": false, + "noEmit": true, + "types": ["node"] + }, + "files": ["hashWasm.cts", "hashWasm.mts"] +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index 8e2e9cf5b..3c092be87 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -44,6 +44,12 @@ const { } = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.js') const { snapshotArchiveRequestId } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveRequest.js') const { snapshotArchiveDatabaseNow } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveSql.js') +const { KnexSnapshotArchiveRpc } = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.js') +const { SnapshotArchiveTransport } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveTransport.js') +const { openRemoteSnapshot } = require('../../out/src/storage/snapshot/archive/openRemoteSnapshot.js') +const { + SnapshotArchiveTransportFailure +} = require('../../out/src/storage/snapshot/archive/SnapshotArchiveTransportFailure.js') const { verifySnapshotArchiveDirectory, verifySnapshotArchivePage @@ -160,6 +166,7 @@ async function captureFixture() { assert.deepEqual(proofPage.rows[0].rawTx, new Uint8Array([1, 2, 255])) await store.close(identity, manifest.archiveId) const requestLifecycle = await requestFixture(writer, reader) + const remoteReader = await readerFixture(writer, reader) await writer.insertCommission({ created_at: date, updated_at: date, @@ -182,7 +189,8 @@ async function captureFixture() { originalSchema: true, packedBinary: true, crossProfileClosureRejected: true, - requestLifecycle + requestLifecycle, + remoteReader } } finally { KnexSnapshotArchiveStore.prototype.append = originalAppend @@ -190,6 +198,104 @@ async function captureFixture() { await writer.destroy() } } +async function readerFixture(writer, reader) { + const owner = new KnexSnapshotArchiveRpc(writer) + const replacement = new KnexSnapshotArchiveRpc(reader) + const originalOpen = writer.openSnapshotArchiveSource.bind(writer) + const retained = Number((await writer.knex('snapshot_archive_requests').count({ count: '*' }).first()).count) + let captures = 0 + let receiver = owner + let admissionLost = false + const admissions = [] + writer.openSnapshotArchiveSource = async (...args) => { + captures++ + return await originalOpen(...args) + } + const transport = new SnapshotArchiveTransport( + async (method, params) => { + const value = await receiver.dispatch(method, params, identity) + if (method === 'admitSnapshotArchive') { + admissions.push(params) + if (!admissionLost) { + admissionLost = true + throw new SnapshotArchiveTransportFailure('synthetic lost admission acknowledgement') + } + } + return value + }, + identity, + 'native-source', + 'test', + true + ) + try { + for (let iteration = 0; iteration < 5; iteration++) { + receiver = owner + const view = await openRemoteSnapshot(transport) + assert.ok(view) + try { + assert.equal(view.sourceStorage.dbtype, 'MySQL') + assert.equal(view.user.identityKey, identity) + assert.ok(view.user.created_at instanceof Date) + assert.equal(writer.snapshotSyncSource, undefined) + receiver = replacement + if (iteration === 0) + await writer.knex('tx_labels').where({ label: 'after-service-pin' }).update({ label: 'after-reader-pin' }) + const labels = [] + let cursor + let done = false + while (!done) { + const page = await view.readPage('txLabels', cursor, { maxRows: 17 }) + assert.ok(page.rows.length <= 17) + assert.equal(page.cursor.archivePosition.version, 1) + assert.equal(page.cursor.archivePosition.archiveId.length, 64) + labels.push(...page.rows) + cursor = page.cursor + done = page.done + } + assert.equal(labels.length, 140) + assert.equal(labels[0].label, iteration === 0 ? 'after-service-pin' : 'after-reader-pin') + assert.equal(new Set(labels.map(row => row.txLabelId)).size, 140) + assert.ok(labels.every(row => row.userId === view.user.userId && row.created_at instanceof Date)) + assert.ok(labels.every(row => typeof row.isDeleted === 'boolean')) + for (const table of snapshotArchiveTables) { + const page = await view.readPage(table, undefined, { maxRows: 1000 }) + assert.equal(page.done, true) + if (table === 'provenTxs') assert.deepEqual(page.rows[0].rawTx, new Uint8Array([1, 2, 255])) + } + } finally { + await view.close() + } + assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).reservedBytes), 0) + assert.equal((await writer.knex('snapshot_archive_requests')).length, retained) + assert.equal((await writer.knex('snapshot_archive_pages')).length, 0) + } + assert.equal(captures, 5) + assert.equal(admissions.length, 6) + assert.deepEqual(admissions[0], admissions[1]) + const issued = await transport.readerOffer({ lifetimeMs: 300000, maxBytes: 1048576 }) + assert.equal(issued.outcome, 'offered') + await transport.cancelRequest(issued.request) + await assert.rejects(transport.admit(issued.request), /unavailable/) + assert.equal(captures, 5) + assert.equal((await writer.knex('snapshot_archive_requests')).length, retained) + return { + transport: 'profile-bound dispatcher over independent MySQL connections; HTTP qualified separately', + tables: 13, + captures, + maxPageRows: 17, + pinnedConcurrentWrite: true, + packedBytesAndDates: true, + replacementReader: true, + exactLostAdmissionRetry: true, + readerReceiptsCollected: true, + cancellationBeforeAdmission: true + } + } finally { + writer.openSnapshotArchiveSource = originalOpen + await Promise.all([owner.close(), replacement.close()]) + } +} async function requestFixture(writer, reader) { const controller = new KnexSnapshotArchiveService(writer) const replacement = new KnexSnapshotArchiveService(reader) diff --git a/packages/wallet/wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts new file mode 100644 index 000000000..62ccf8ec8 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts @@ -0,0 +1,115 @@ +// Intended location: wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts +import type { WalletSnapshotTable } from '../../src/storage/snapshot/WalletReadSnapshot' +import { + snapshotArchiveReaderRequestId, + type SnapshotArchiveReaderRequest +} from '../../src/storage/snapshot/archive/SnapshotArchiveReaderRequest' +import type { SnapshotArchiveRpcCall } from '../../src/storage/snapshot/archive/SnapshotArchiveTransport' +import { SnapshotArchiveTransport } from '../../src/storage/snapshot/archive/SnapshotArchiveTransport' +import { encodeSyncTransfer } from '../../src/storage/remoting/SyncTransfer' +import { fixture, expected, hash, rehash, tables } from './snapshotArchiveDirectoryFixtures' + +export function label(id: number, text = `label-${id}`) { + return { + created_at: new Date('2026-01-01T00:00:00.000Z'), + updated_at: new Date('2026-01-01T00:00:00.000Z'), + txLabelId: id, + userId: 7, + label: text, + isDeleted: id % 3 === 0 + } +} + +/** Independent complete receipt chain with real binary frames and no SQL/network dependency. */ +export function remoteReaderFixture(rows: object[], table: WalletSnapshotTable = 'txLabels', frameRows = 17) { + const { directory, binding } = fixture() + const frames: Uint8Array[] = [] + directory.receipts = [] + for (const name of tables) { + const selected = name === table ? rows : [] + const count = Math.max(1, Math.ceil(selected.length / frameRows)) + for (let index = 0; index < count; index++) { + const values = selected.slice(index * frameRows, (index + 1) * frameRows) + const bytes = encodeSyncTransfer({ version: 1, table: name, rows: values }) + frames.push(bytes) + directory.receipts.push({ + sequence: frames.length - 1, + table: name, + rows: values.length, + done: index === count - 1, + digest: hash(bytes) + }) + } + } + directory.pages = frames.length + directory.rows = rows.length + rehash(directory) + let request: SnapshotArchiveReaderRequest | undefined + const receipt = () => { + if (request === undefined) throw new Error('Fixture request has not been admitted') + return { + version: 1, + requestId: request.requestId, + expiresAt: request.notAfter, + state: 'ready', + archiveId: directory.archiveId, + digest: directory.digest + } + } + const implementation: SnapshotArchiveRpcCall = async (method, params) => { + const input = params[0] as { + request?: SnapshotArchiveReaderRequest + sequence?: number + options?: { lifetimeMs: number; maxBytes: number } + } + switch (method) { + case 'getSnapshotArchiveReaderOffer': { + const serverTime = Date.now() + const fields = { + version: 2 as const, + nonce: 'a'.repeat(64), + notAfter: serverTime + input.options!.lifetimeMs, + maxBytes: input.options!.maxBytes + } + request = { ...fields, requestId: snapshotArchiveReaderRequestId(fields) } + directory.expiresAt = request.notAfter + return { + version: 1, + outcome: 'offered', + request, + offer: { + version: 1, + serverTime, + sourceStorageIdentityKey: expected.sourceStorageIdentityKey, + sourceSchema: binding.sourceSchema, + chain: 'test' + } + } + } + case 'admitSnapshotArchive': + if (input.request?.requestId !== request?.requestId) throw new Error('Fixture request was not offered') + return { version: 1, outcome: 'accepted', receipt: receipt() } + case 'getSnapshotArchiveStatus': + return receipt() + case 'getSnapshotArchiveDirectory': + return directory + case 'readSnapshotArchivePage': { + const sequence = input.sequence! + return { ...directory.receipts[sequence], bytes: frames[sequence] } + } + case 'cancelSnapshotArchiveRequest': + return true + default: + throw new Error(`Unexpected fixture operation ${method}`) + } + } + const rpc = jest.fn(implementation) + const transport = new SnapshotArchiveTransport( + rpc, + expected.identityKey, + expected.sourceStorageIdentityKey, + 'test', + true + ) + return { transport, rpc, frames, directory, binding, receipt } +} diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts index cd494d1e2..3b4f69b43 100644 --- a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts @@ -18,7 +18,7 @@ export function gate() { return { promise, resolve } } -export async function snapshotHttpFixture(snapshotSync = true) { +export async function snapshotHttpFixture(snapshotSync = true, seedClosure = true) { const directory = await mkdtemp(join(tmpdir(), 'snapshot-http-')) const key = PrivateKey.fromRandom() const identityKey = key.toPublicKey().toString() @@ -47,7 +47,7 @@ export async function snapshotHttpFixture(snapshotSync = true) { await storage.makeAvailable() const { user } = await storage.findOrInsertUser(identityKey) const { user: other } = await storage.findOrInsertUser(PrivateKey.fromRandom().toPublicKey().toString()) - await seedArchiveClosure(storage, user.userId, other.userId) + if (seedClosure) await seedArchiveClosure(storage, user.userId, other.userId) const serve = async (options: Partial = {}) => { const server = new StorageServer(storage, { port: 0, diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index fe97670dd..c33ca3772 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -27,7 +27,7 @@ function workflowJobBlocks(workflow) { function assertWalletMutationTimeout(job, defaultMinutes) { const targets = - '["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows"]' + '["wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http"]' const expected = ` timeout-minutes: \${{ contains(fromJSON('${targets}'), matrix.target) && 90 || ${defaultMinutes} }}` assert.equal(job.source.match(/^ timeout-minutes: .+$/m)?.[0], expected) } diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index fd9f42c2d..bd90bb269 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 41) + assert.equal(result.summary.propertySuites, 42) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 37) - assert.equal(result.summary.mutationTargets, 41) + assert.equal(result.summary.mutationTargets, 42) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 20c22b957..473d42ded 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -148,6 +148,23 @@ other failures. This advances the S3 transport portion. The clock/cursor-compati remote row reader, sync-manager adoption, remote destination, portable semantics and performance qualification under contention remain open. +The next unadvertised reader implementation adds distinct server-issued v2 +offers, require-existing admission, bounded exact-request retry, fixed client +leases and verified packed row paging. Successful explicit close can collect a +reader receipt without reopening it; failure receipts remain pollable until +cancellation or expiry. The client source adapter and local manager/destination +integration detach, compare and persist optional archive positions and preserve +typed cancellation without treating unrelated failures as cancellation or +fallback. Actual full/mobile HTTP fixtures cover all thirteen row schemas, +replacement, admission loss and cancellation, alongside a generated reader +property. A complete-suite restart failure exposed a reused HTTP connection +reset; immutable requests now permit one native-fetch retry without renewing +the lease or retrying authentication failures. Actual full/mobile HTTP-to-local +sync fixtures retain durable positions on cancellation and complete a later +view into an occupied profile without duplicate labels. The production server reader setting remains absent pending owner +recovery and physical cleanup qualification across replicas. This checkpoint +does not complete S3, V1, remote destination support or portable export/import. + These checkpoints advance parts of S1/S2/P1/S4. They do not complete primary reconciliation, indexed identity/update predicates and commit-order high-water positions, authenticated remote views, durable source views, streaming or staged From 214f8aae09430ea176a3b47d2703efac8c072a2d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 20:11:00 -0700 Subject: [PATCH 059/127] ci: require canonical retained snapshot partition aggregates --- .github/workflows/ci.yml | 60 +++++++--- .github/workflows/mutation-tests.yml | 55 +++++---- docs/reference/ci-performance.md | 23 +++- .../mutation-workflows.test.mjs | 107 ++++++++++++++++-- .../sdk-auth-partitions.test.mjs | 41 +++---- scripts/ci-orchestration.test.mjs | 3 + scripts/mutation-partition-evidence.mjs | 7 +- scripts/mutation-partitions.mjs | 46 ++++++-- scripts/mutation-partitions.test.mjs | 55 ++++++++- 9 files changed, 322 insertions(+), 75 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae351f81..4f3aea5ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,7 @@ jobs: mutation-targets: ${{ steps.scope.outputs.mutation-targets }} mutation-classification: ${{ steps.scope.outputs.mutation-classification }} mutation-matrix: ${{ steps.scope.outputs.mutation-matrix }} + partition-targets: ${{ steps.scope.outputs.partition-targets }} infra-matrix: ${{ steps.scope.outputs.infra-matrix }} has-infra: ${{ steps.scope.outputs.has-infra }} docs: ${{ steps.scope.outputs.docs }} @@ -144,8 +145,10 @@ jobs: echo "conformance=$(jq -r '.conformance' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" echo "mutation-targets=$MUTATION_TARGETS" >> "$GITHUB_OUTPUT" echo "mutation-classification=$MUTATION_SCOPE" >> "$GITHUB_OUTPUT" - echo "mutation-matrix=$(node scripts/mutation-partition-evidence.mjs matrix --selected \ - "$MUTATION_TARGETS")" >> "$GITHUB_OUTPUT" + MUTATION_MATRIX=$(node scripts/mutation-partition-evidence.mjs matrix --selected "$MUTATION_TARGETS") + PARTITION_TARGETS=$(node scripts/mutation-partition-evidence.mjs targets --selected "$MUTATION_TARGETS") + echo "mutation-matrix=$MUTATION_MATRIX" >> "$GITHUB_OUTPUT" + echo "partition-targets=$PARTITION_TARGETS" >> "$GITHUB_OUTPUT" echo "has-infra=$(jq -r '.infraMatrix.include | length > 0' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" { @@ -232,6 +235,7 @@ jobs: mutation-targets: ${{ needs.scope.outputs.mutation-targets }} mutation-classification: ${{ needs.scope.outputs.mutation-classification }} mutation-matrix: ${{ needs.scope.outputs.mutation-matrix }} + partition-targets: ${{ needs.scope.outputs.partition-targets }} sdk: ${{ steps.scope.outputs.sdk }} did: ${{ steps.scope.outputs.did }} wallet: ${{ steps.scope.outputs.wallet }} @@ -553,6 +557,8 @@ jobs: MUTATION_RESULT: ${{ needs.mutation-tests.result }} MUTATION_TARGETS: ${{ needs.prepare.outputs.mutation-targets }} MUTATION_CLASSIFICATION: ${{ needs.prepare.outputs.mutation-classification }} + MUTATION_MATRIX: ${{ needs.prepare.outputs.mutation-matrix }} + PARTITION_TARGETS: ${{ needs.prepare.outputs.partition-targets }} run: | if [[ "$PREPARE_RESULT" != "success" || ( "$MUTATION_RESULT" != "success" && @@ -560,41 +566,61 @@ jobs: echo "::error::Mutation quality failed: prepare=$PREPARE_RESULT mutation=$MUTATION_RESULT" exit 1 fi + "${NODE_EXECUTABLE:-node}" --input-type=module <<'NODE' + const matrix = JSON.parse(process.env.MUTATION_MATRIX) + const actual = JSON.parse(process.env.PARTITION_TARGETS) + if (!Array.isArray(matrix.include) || !Array.isArray(actual) || + actual.some(target => typeof target !== 'string') || new Set(actual).size !== actual.length) + throw new Error('Invalid canonical partition selection') + const expected = [...new Set(matrix.include.filter(row => row.partition !== 'whole').map(row => row.target))].sort() + if (JSON.stringify([...actual].sort()) !== JSON.stringify(expected)) + throw new Error('Canonical partition targets do not match the execution matrix') + NODE echo '### Required PR mutation qualification' >> "$GITHUB_STEP_SUMMARY" echo "Required targets: $MUTATION_TARGETS" >> "$GITHUB_STEP_SUMMARY" echo "Deferred, not passed: $(jq -c '.deferred' <<<"$MUTATION_CLASSIFICATION")" >> "$GITHUB_STEP_SUMMARY" echo 'Full qualification is required separately at the exact publication candidate.' >> "$GITHUB_STEP_SUMMARY" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: contains(fromJSON(needs.prepare.outputs.mutation-targets || '[]'), 'sdk-auth-http') + if: needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' with: persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - if: contains(fromJSON(needs.prepare.outputs.mutation-targets || '[]'), 'sdk-auth-http') + if: needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - if: contains(fromJSON(needs.prepare.outputs.mutation-targets || '[]'), 'sdk-auth-http') + if: needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' with: node-version: 24.18.0 cache: pnpm - - name: Install pinned SDKAuth aggregate verifier - if: contains(fromJSON(needs.prepare.outputs.mutation-targets || '[]'), 'sdk-auth-http') + - name: Install pinned partition aggregate verifier + if: needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' run: pnpm install --frozen-lockfile --ignore-scripts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - if: contains(fromJSON(needs.prepare.outputs.mutation-targets || '[]'), 'sdk-auth-http') + if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'sdk-auth-http') with: pattern: mutation-sdk-auth-http-* - path: .sdk-auth-parts - - name: Require the unchanged global SDKAuth target gate - if: contains(fromJSON(needs.prepare.outputs.mutation-targets || '[]'), 'sdk-auth-http') + path: .mutation-parts/sdk-auth-http + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-retained-snapshot') + with: + pattern: mutation-wallet-retained-snapshot-* + path: .mutation-parts/wallet-retained-snapshot + - name: Require every selected canonical partition target gate + if: needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' + env: + PARTITION_TARGETS: ${{ needs.prepare.outputs.partition-targets }} run: | - node scripts/mutation-partition-evidence.mjs verify --mode diagnostic \ - --target sdk-auth-http --directory .sdk-auth-parts \ - --output artifacts/mutation/sdk-auth-http + TARGET_LINES=$(jq -er '.[]' <<<"$PARTITION_TARGETS") + while IFS= read -r target; do + node scripts/mutation-partition-evidence.mjs verify --mode diagnostic \ + --target "$target" --directory ".mutation-parts/$target" \ + --output "artifacts/mutation/$target" + done <<<"$TARGET_LINES" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: always() && contains(fromJSON(needs.prepare.outputs.mutation-targets || '[]'), 'sdk-auth-http') + if: always() && needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' with: - name: mutation-sdk-auth-http-canonical - path: artifacts/mutation/sdk-auth-http + name: mutation-partition-canonical + path: artifacts/mutation if-no-files-found: ignore retention-days: 30 diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 032324463..f65a8e8f7 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -32,6 +32,7 @@ jobs: targets: ${{ steps.targets.outputs.targets }} mode: ${{ steps.targets.outputs.mode }} mutation-matrix: ${{ steps.targets.outputs.mutation-matrix }} + partition-targets: ${{ steps.targets.outputs.partition-targets }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -62,8 +63,10 @@ jobs: TARGETS=$(jq -R -s -c 'split("\n") | map(select(length > 0))' <<<"$GOVERNED_TARGETS") fi echo "targets=$TARGETS" >> "$GITHUB_OUTPUT" - echo "mutation-matrix=$(node scripts/mutation-partition-evidence.mjs matrix --selected \ - "$TARGETS")" >> "$GITHUB_OUTPUT" + MUTATION_MATRIX=$(node scripts/mutation-partition-evidence.mjs matrix --selected "$TARGETS") + PARTITION_TARGETS=$(node scripts/mutation-partition-evidence.mjs targets --selected "$TARGETS") + echo "mutation-matrix=$MUTATION_MATRIX" >> "$GITHUB_OUTPUT" + echo "partition-targets=$PARTITION_TARGETS" >> "$GITHUB_OUTPUT" if [ -n "$REQUESTED_TARGET" ]; then echo "mode=diagnostic" >> "$GITHUB_OUTPUT" else @@ -141,7 +144,7 @@ jobs: --mode "$CAMPAIGN_MODE" --target "$TARGET" \ --directory "artifacts/mutation/$TARGET" - - name: Capture complete SDKAuth partition evidence + - name: Capture complete execution-partition evidence if: matrix.partition != 'whole' env: CAMPAIGN_MODE: ${{ needs.prepare.outputs.mode }} @@ -158,10 +161,15 @@ jobs: if-no-files-found: ignore retention-days: 30 - sdk-auth-aggregate: - name: Canonical SDKAuth global gate - if: always() && !cancelled() && needs.prepare.result == 'success' && needs.mutation-tests.result == 'success' && contains(fromJSON(needs.prepare.outputs.targets || '[]'), 'sdk-auth-http') + partition-aggregate: + name: Canonical global gate / ${{ matrix.target }} + if: always() && !cancelled() && needs.prepare.result == 'success' && needs.mutation-tests.result == 'success' && needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' needs: [prepare, mutation-tests] + strategy: + fail-fast: false + max-parallel: 2 + matrix: + target: ${{ fromJSON(needs.prepare.outputs.partition-targets) }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -179,20 +187,20 @@ jobs: run: pnpm install --frozen-lockfile --ignore-scripts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - pattern: mutation-partition-${{ github.run_id }}-${{ github.run_attempt }}-sdk-auth-http-* - path: .sdk-auth-parts + pattern: mutation-partition-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.target }}-* + path: .mutation-parts - name: Require the original global score and exhaustive canonical inventory env: CAMPAIGN_MODE: ${{ needs.prepare.outputs.mode }} run: | node scripts/mutation-partition-evidence.mjs verify --mode "$CAMPAIGN_MODE" \ - --target sdk-auth-http --directory .sdk-auth-parts \ - --output artifacts/mutation/sdk-auth-http + --target "${{ matrix.target }}" --directory .mutation-parts \ + --output "artifacts/mutation/${{ matrix.target }}" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: - name: mutation-receipt-${{ github.run_id }}-${{ github.run_attempt }}-sdk-auth-http - path: artifacts/mutation/sdk-auth-http + name: mutation-receipt-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.target }} + path: artifacts/mutation/${{ matrix.target }} if-no-files-found: ignore retention-days: 30 @@ -202,7 +210,7 @@ jobs: needs: - prepare - mutation-tests - - sdk-auth-aggregate + - partition-aggregate runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -214,15 +222,16 @@ jobs: env: PREPARE_RESULT: ${{ needs.prepare.result }} MUTATION_RESULT: ${{ needs.mutation-tests.result }} - SDK_AUTH_RESULT: ${{ needs.sdk-auth-aggregate.result }} - SDK_AUTH_REQUIRED: ${{ contains(fromJSON(needs.prepare.outputs.targets || '[]'), 'sdk-auth-http') }} + PARTITION_RESULT: ${{ needs.partition-aggregate.result }} + PARTITION_TARGETS: ${{ needs.prepare.outputs.partition-targets }} run: | if [[ "$PREPARE_RESULT" != "success" || "$MUTATION_RESULT" != "success" ]]; then echo "::error::Mutation campaign failed: prepare=$PREPARE_RESULT mutation=$MUTATION_RESULT" exit 1 fi - if [[ "$SDK_AUTH_REQUIRED" == "true" && "$SDK_AUTH_RESULT" != "success" ]]; then - echo "::error::Complete SDKAuth global gate failed: $SDK_AUTH_RESULT" + jq -e 'type == "array" and all(.[]; type == "string")' <<<"$PARTITION_TARGETS" > /dev/null + if [[ "$PARTITION_TARGETS" != "[]" && "$PARTITION_RESULT" != "success" ]]; then + echo "::error::Complete canonical partition gate failed: $PARTITION_RESULT" exit 1 fi @@ -241,13 +250,17 @@ jobs: with: pattern: mutation-receipt-${{ github.run_id }}-${{ github.run_attempt }}-* path: .mutation-receipts - - name: Independently recheck all raw SDKAuth partitions before full qualification - if: contains(fromJSON(needs.prepare.outputs.targets || '[]'), 'sdk-auth-http') + - name: Independently recheck every selected canonical partition before full qualification + if: needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' env: CAMPAIGN_MODE: ${{ needs.prepare.outputs.mode }} + PARTITION_TARGETS: ${{ needs.prepare.outputs.partition-targets }} run: | - node scripts/mutation-partition-evidence.mjs recheck --mode "$CAMPAIGN_MODE" \ - --target sdk-auth-http --directory .mutation-receipts + TARGET_LINES=$(jq -er '.[]' <<<"$PARTITION_TARGETS") + while IFS= read -r target; do + node scripts/mutation-partition-evidence.mjs recheck --mode "$CAMPAIGN_MODE" \ + --target "$target" --directory .mutation-receipts + done <<<"$TARGET_LINES" - name: Issue full qualification only for every exact-source canonical target id: qualification env: diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index 33b3a1acb..afef1f90a 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -2,7 +2,7 @@ id: ci-performance title: 'CI Performance Governance' kind: reference -version: '1.3.4' +version: '1.3.5' last_updated: '2026-10-01' last_verified: '2026-10-01' review_cadence_days: 30 @@ -332,3 +332,24 @@ billing estimate. Record comparable exact-source elapsed time, total execution, dry-run and aggregate overhead before claiming a reduction. Wallet retained snapshot partition adoption is independently owned and is not implemented by this SDKAuth change. + +### Retained snapshot partition adoption + +The same required canonical aggregation supports an actually registered +`wallet-retained-snapshot`: whole retained lifecycle, whole Knex reader, and +all original StorageKnex/StorageProvider ranges. Future canonical sources join +lifecycle; every part keeps the complete original tests and property/config +settings. Selection creates no target absent from the current registry. PR and +full qualification require every selected partitioned target's original global +gate and full publication independently reconstructs its raw evidence. + +Historical source `7539b7109de2c2cffa220b7a9df622ab807ab269` has exactly +110 lifecycle + 455 reader + 39 storage = 604 distinct canonical mutants. Its +hosted job ran 89m53s before a 90-minute timeout without a final report. The +reader contains 75% of these sites, so file splitting can leave a substantial +tail. Counts do not predict runtime; repeated complete dry runs, verifier setup +and six-slot contention add compute. No measured improvement is established. +This source proof cannot qualify a newer wallet head: its owner must compare +the full pinned inventory/configuration on the final source before adoption. +Registry, runtime, assertions, workers, deadlines and thresholds remain owned +and unchanged by the partition facility. diff --git a/scripts/ci-integration/mutation-workflows.test.mjs b/scripts/ci-integration/mutation-workflows.test.mjs index f8ec70fef..494803312 100644 --- a/scripts/ci-integration/mutation-workflows.test.mjs +++ b/scripts/ci-integration/mutation-workflows.test.mjs @@ -88,12 +88,18 @@ test('full campaign receipts cannot borrow another attempt or a partial manual t /mode=diagnostic/ ) const gate = workflow.jobs['mutation-quality'] - assert.deepEqual(gate.needs, ['prepare', 'mutation-tests', 'sdk-auth-aggregate']) + assert.deepEqual(gate.needs, ['prepare', 'mutation-tests', 'partition-aggregate']) const script = gate.steps.find(step => step.name === 'Verify the campaign').run for (const prepare of ['success', 'failure', 'cancelled', 'skipped', '']) { for (const mutation of ['success', 'failure', 'cancelled', 'skipped', '']) { const run = spawnSync('/bin/bash', ['-e', '-c', script], { - env: { PREPARE_RESULT: prepare, MUTATION_RESULT: mutation, SDK_AUTH_REQUIRED: 'false' }, + env: { + PREPARE_RESULT: prepare, + MUTATION_RESULT: mutation, + PARTITION_TARGETS: '[]', + PARTITION_RESULT: 'skipped', + PATH: process.env.PATH + }, encoding: 'utf8' }) assert.equal(run.status === 0, prepare === 'success' && mutation === 'success') @@ -114,6 +120,18 @@ test('full campaign receipts cannot borrow another attempt or a partial manual t /mutation-final-qualification\.mjs verify/ ) const ci = parse(readFileSync(CI_PATH, 'utf8')) + assert.equal( + ci.jobs.prepare.outputs['partition-targets'], + '${{ needs.scope.outputs.partition-targets }}' + ) + assert.equal( + ci.jobs.scope.outputs['partition-targets'], + '${{ steps.scope.outputs.partition-targets }}' + ) + assert.equal( + workflow.jobs.prepare.outputs['partition-targets'], + '${{ steps.targets.outputs.partition-targets }}' + ) const deadline = workflow.jobs['mutation-tests']['timeout-minutes'] assert.equal(ci.jobs['mutation-tests']['timeout-minutes'], deadline) const allowance = JSON.parse(/fromJSON\('([^']+)'\)/.exec(deadline)[1]) @@ -129,7 +147,7 @@ test('full campaign receipts cannot borrow another attempt or a partial manual t assert.match(deadline, /&& 90 \|\| 45/) }) -test('SDKAuth partial jobs cannot replace the original canonical global gate or the final raw-part recheck', async () => { +test('partition jobs cannot replace each original canonical global gate or the final raw-part recheck', async () => { const { parse } = await import('yaml') const full = parse( readFileSync(join(REPOSITORY_ROOT, '.github/workflows/mutation-tests.yml'), 'utf8') @@ -150,25 +168,98 @@ test('SDKAuth partial jobs cannot replace the original canonical global gate or for (const result of ['success', 'failure', 'cancelled', 'skipped', '']) { const run = spawnSync('/bin/bash', ['-e', '-c', script], { env: { + PATH: process.env.PATH, PREPARE_RESULT: 'success', MUTATION_RESULT: 'success', - SDK_AUTH_REQUIRED: 'true', - SDK_AUTH_RESULT: result + PARTITION_TARGETS: JSON.stringify(['sdk-auth-http', 'wallet-retained-snapshot']), + PARTITION_RESULT: result } }) assert.equal(run.status === 0, result === 'success') } const recheck = gate.steps.findIndex( step => - step.name === 'Independently recheck all raw SDKAuth partitions before full qualification' + step.name === + 'Independently recheck every selected canonical partition before full qualification' ) assert.ok(recheck >= 0 && recheck < gate.steps.findIndex(step => step.id === 'qualification')) assert.match(gate.steps[recheck].run, /mutation-partition-evidence\.mjs recheck/) assert.match( ci.jobs['mutation-quality'].steps.find( - step => step.name === 'Require the unchanged global SDKAuth target gate' + step => step.name === 'Require every selected canonical partition target gate' ).run, /mutation-partition-evidence\.mjs verify/ ) - assert.deepEqual(full.jobs['sdk-auth-aggregate'].needs, ['prepare', 'mutation-tests']) + assert.deepEqual(full.jobs['partition-aggregate'].needs, ['prepare', 'mutation-tests']) + assert.match(full.jobs['partition-aggregate'].strategy.matrix.target, /partition-targets/) + for (const id of ['sdk-auth-http', 'wallet-retained-snapshot']) { + const download = ci.jobs['mutation-quality'].steps.find( + step => step.with?.pattern === `mutation-${id}-*` + ) + assert.ok(download) + assert.match(download.if, /partition-targets/) + assert.equal(download.with.path, `.mutation-parts/${id}`) + } + for (const targets of ['[]', '', 'null']) { + const run = spawnSync('/bin/bash', ['-e', '-c', script], { + env: { + PREPARE_RESULT: 'success', + MUTATION_RESULT: 'success', + PARTITION_TARGETS: targets, + PARTITION_RESULT: 'skipped', + PATH: process.env.PATH + } + }) + assert.equal(run.status === 0, targets === '[]') + } +}) + +test('PR partial execution cannot qualify when canonical aggregate selection is absent, empty or incomplete', async () => { + const { parse } = await import('yaml') + const ci = parse(readFileSync(CI_PATH, 'utf8')) + const script = ci.jobs['mutation-quality'].steps.find( + step => step.name === 'Verify the affected mutation targets' + ).run + const targets = ['sdk-auth-http', 'wallet-retained-snapshot'] + const matrix = { + include: targets.flatMap(target => + ['first', 'second'].map(partition => ({ target, partition })) + ) + } + for (const selection of [ + '', + '[]', + 'null', + '["sdk-auth-http"]', + '["sdk-auth-http","sdk-auth-http"]', + JSON.stringify(targets) + ]) { + const run = spawnSync('/bin/bash', ['-e', '-c', script], { + env: { + PATH: process.env.PATH, + PREPARE_RESULT: 'success', + MUTATION_RESULT: 'success', + MUTATION_TARGETS: JSON.stringify(targets), + MUTATION_CLASSIFICATION: '{"deferred":[]}', + MUTATION_MATRIX: JSON.stringify(matrix), + PARTITION_TARGETS: selection, + GITHUB_STEP_SUMMARY: '/dev/null' + } + }) + assert.equal(run.status === 0, selection === JSON.stringify(targets)) + } + const empty = spawnSync('/bin/bash', ['-e', '-c', script], { + env: { + PATH: process.env.PATH, + NODE_EXECUTABLE: process.execPath, + PREPARE_RESULT: 'success', + MUTATION_RESULT: 'skipped', + MUTATION_TARGETS: '[]', + MUTATION_CLASSIFICATION: '{"deferred":[]}', + MUTATION_MATRIX: '{"include":[]}', + PARTITION_TARGETS: '[]', + GITHUB_STEP_SUMMARY: '/dev/null' + } + }) + assert.equal(empty.status, 0) }) diff --git a/scripts/ci-integration/sdk-auth-partitions.test.mjs b/scripts/ci-integration/sdk-auth-partitions.test.mjs index c86a37ab7..95169ce35 100644 --- a/scripts/ci-integration/sdk-auth-partitions.test.mjs +++ b/scripts/ci-integration/sdk-auth-partitions.test.mjs @@ -18,22 +18,25 @@ const tuple = mutant => mutant.status, mutant.statusReason ]) -test('actual pinned SDKAuth partitions exhaust the original canonical mutant inventory and complete test configuration', async () => { - const id = 'sdk-auth-http', - target = buildMutationTargets(root)[id] - const canonical = await targetEvidence(root, target, id) - const parts = await Promise.all( - partitionMutationTarget(id, target).map(async part => ({ - ...part, - evidence: await targetEvidence(root, part.target, id, part.id) - })) - ) - const actual = parts.flatMap(part => part.evidence.mutants.map(tuple)).sort() - assert.deepEqual(actual, canonical.mutants.map(tuple).sort()) - assert.equal(new Set(actual).size, actual.length) - for (const part of parts) { - assert.deepEqual(part.target.runnerOptions, target.runnerOptions) - const comparable = ({ mutate: _mutate, jsonReporter: _reporter, ...config }) => config - assert.deepEqual(comparable(part.evidence.config), comparable(canonical.config)) - } -}) +const targets = buildMutationTargets(root) +for (const id of ['sdk-auth-http', 'wallet-retained-snapshot'].filter(id => + Object.hasOwn(targets, id) +)) + test(`actual pinned ${id} partitions exhaust the original canonical mutant inventory and complete test configuration`, async () => { + const target = targets[id] + const canonical = await targetEvidence(root, target, id) + const parts = await Promise.all( + partitionMutationTarget(id, target).map(async part => ({ + ...part, + evidence: await targetEvidence(root, part.target, id, part.id) + })) + ) + const actual = parts.flatMap(part => part.evidence.mutants.map(tuple)).sort() + assert.deepEqual(actual, canonical.mutants.map(tuple).sort()) + assert.equal(new Set(actual).size, actual.length) + for (const part of parts) { + assert.deepEqual(part.target.runnerOptions, target.runnerOptions) + const comparable = ({ mutate: _mutate, jsonReporter: _reporter, ...config }) => config + assert.deepEqual(comparable(part.evidence.config), comparable(canonical.config)) + } + }) diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index c5fc0fb58..567a0b95a 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -389,6 +389,9 @@ test('the mutation quality job accepts skipped execution only for explicitly emp for (const result of ['success', 'skipped', 'failure', 'cancelled', '']) { const execution = spawnSync('/bin/bash', ['-e', '-c', script], { env: { + NODE_EXECUTABLE: process.execPath, + MUTATION_MATRIX: '{"include":[]}', + PARTITION_TARGETS: '[]', PREPARE_RESULT: 'success', MUTATION_TARGETS: targets, MUTATION_RESULT: result, diff --git a/scripts/mutation-partition-evidence.mjs b/scripts/mutation-partition-evidence.mjs index 96530fc4b..f1b5fa3fd 100644 --- a/scripts/mutation-partition-evidence.mjs +++ b/scripts/mutation-partition-evidence.mjs @@ -7,6 +7,7 @@ import { isDeepStrictEqual } from 'node:util' import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' import { partitionMutationTarget, + partitionedMutationTargets, selectedMutationPartition, mutationExecutionMatrix } from './mutation-partitions.mjs' @@ -179,7 +180,7 @@ function packetDirectories(directory, file) { } function parseArguments(argv) { const [command, ...rest] = argv - if (!['matrix', 'capture', 'verify', 'recheck'].includes(command)) + if (!['matrix', 'targets', 'capture', 'verify', 'recheck'].includes(command)) throw new Error('Unknown partition command') const options = { command } for (let index = 0; index < rest.length; index += 2) { @@ -221,6 +222,10 @@ async function main(argv) { console.log(JSON.stringify(mutationExecutionMatrix(JSON.parse(options.selected), targets))) return } + if (options.command === 'targets') { + console.log(JSON.stringify(partitionedMutationTargets(JSON.parse(options.selected), targets))) + return + } const { identity, policy, parts, canonical } = await context( options.target, options.mode, diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index 4100c6250..1a63da316 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -1,27 +1,59 @@ import path from 'node:path' const partitionFile = specification => specification.replace(/:\d+(?:-\d+)?$/, '') +const plans = new Map([ + [ + 'sdk-auth-http', + { + fallback: 'core', + files: new Map([ + ['src/auth/clients/AuthFetch.ts', 'client'], + ['src/auth/transports/SimplifiedFetchTransport.ts', 'transport'] + ]) + } + ], + [ + 'wallet-retained-snapshot', + { + fallback: 'lifecycle', + files: new Map([ + ['src/storage/snapshot/KnexWalletReadSnapshot.ts', 'reader'], + ['src/storage/StorageKnex.ts', 'storage'], + ['src/storage/StorageProvider.ts', 'storage'] + ]) + } + ] +]) // Keep each file's complete original range union in exactly one execution part. -// New canonical files default to core; a future helper can never disappear. +// New canonical files join the target's fallback; a future helper cannot disappear. export function partitionMutationTarget(targetId, target) { - if (targetId !== 'sdk-auth-http') return [{ id: 'whole', target }] + const plan = plans.get(targetId) + if (!plan) return [{ id: 'whole', target }] const groups = new Map() for (const specification of target.mutate) { const file = partitionFile(specification) if (/[!*?{}[\]]/.test(file) || path.posix.isAbsolute(file) || file.split('/').includes('..')) - throw new Error('SDKAuth partition requires explicit canonical source paths') - let id = 'core' - if (file === 'src/auth/clients/AuthFetch.ts') id = 'client' - else if (file === 'src/auth/transports/SimplifiedFetchTransport.ts') id = 'transport' + throw new Error('Mutation partition requires explicit canonical source paths') + const id = plan.files.get(file) ?? plan.fallback const mutate = groups.get(id) ?? [] mutate.push(specification) groups.set(id, mutate) } - if (groups.size === 0) throw new Error('Empty canonical SDKAuth source union') + if (groups.size === 0) throw new Error('Empty canonical mutation source union') return [...groups].map(([id, mutate]) => ({ id, target: { ...target, mutate } })) } +export function partitionedMutationTargets(selected, targets) { + return [ + ...new Set( + mutationExecutionMatrix(selected, targets) + .include.filter(value => value.partition !== 'whole') + .map(value => value.target) + ) + ] +} + export function selectedMutationPartition(targetId, target, partition = 'whole') { if (partition === 'whole') return target const selected = partitionMutationTarget(targetId, target).find(value => value.id === partition) diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 3de811143..4c48a8b28 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -4,7 +4,8 @@ import { parseArguments } from './mutation-testing.mjs' import { partitionMutationTarget, selectedMutationPartition, - mutationExecutionMatrix + mutationExecutionMatrix, + partitionedMutationTargets } from './mutation-partitions.mjs' const target = { mutate: [ @@ -89,3 +90,55 @@ test('partition command mode requires one exact target without weakening existin ]) assert.throws(() => parseArguments(args)) }) + +test('retained partitions preserve complete lifecycle/reader/storage unions and future additions', () => { + const retained = { + testRunner: 'jest', + runnerOptions: { jest: { config: { testMatch: ['all-original-retained-tests'] } } }, + mutate: [ + 'src/storage/snapshot/RetainedReadSnapshot.ts', + 'src/storage/snapshot/KnexWalletReadSnapshot.ts', + 'src/storage/StorageKnex.ts:225-275', + 'src/storage/StorageKnex.ts:250-280', + 'src/storage/StorageProvider.ts:540-562', + 'src/storage/snapshot/FutureHelper.ts' + ] + } + const parts = partitionMutationTarget('wallet-retained-snapshot', retained) + assert.deepEqual( + parts.map(part => part.id), + ['lifecycle', 'reader', 'storage'] + ) + assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...retained.mutate].sort()) + for (const part of parts) { + assert.equal(part.target.runnerOptions, retained.runnerOptions) + assert.equal(part.target.testRunner, retained.testRunner) + } + assert.deepEqual(parts[0].target.mutate, [retained.mutate[0], retained.mutate[5]]) + assert.deepEqual(parts[2].target.mutate, retained.mutate.slice(2, 5)) + for (const specification of [ + 'src/**/*.ts', + '!src/StorageKnex.ts', + '../outside.ts', + '/outside.ts' + ]) + assert.throws(() => + partitionMutationTarget('wallet-retained-snapshot', { mutate: [specification] }) + ) + assert.throws(() => selectedMutationPartition('wallet-retained-snapshot', retained, 'missing')) + const targets = { + 'sdk-auth-http': target, + 'wallet-retained-snapshot': retained, + other: { mutate: ['src/whole.ts'] } + } + assert.deepEqual(partitionedMutationTargets(['other'], targets), []) + assert.deepEqual( + partitionedMutationTargets(['wallet-retained-snapshot', 'other', 'sdk-auth-http'], targets), + ['wallet-retained-snapshot', 'sdk-auth-http'] + ) + assert.deepEqual(partitionedMutationTargets([], targets), []) + assert.throws(() => + partitionedMutationTargets(['wallet-retained-snapshot'], { 'sdk-auth-http': target }) + ) + assert.throws(() => partitionedMutationTargets(['sdk-auth-http', 'sdk-auth-http'], targets)) +}) From 04bd7ea1400cebd497920029a51cbf24eb339e79 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 20:45:10 -0700 Subject: [PATCH 060/127] ci: allow bounded full remote reader mutation qualification --- .github/workflows/ci.yml | 2 +- .github/workflows/mutation-tests.yml | 2 +- docs/reference/ci-performance.md | 17 +++++++++++++++++ .../ci-integration/mutation-workflows.test.mjs | 1 + scripts/ci-orchestration.test.mjs | 2 +- 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f3aea5ef..9153946a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,7 +501,7 @@ jobs: runs-on: ubuntu-latest # The governed air-gap codec target currently instruments 352 mutants and # legitimately exceeds 20 minutes on a hosted runner. - timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http"]'), matrix.target) && 90 || 45 }} + timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index f65a8e8f7..ecc9b6668 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -103,7 +103,7 @@ jobs: needs: prepare runs-on: ubuntu-latest # Preserve the acknowledged wallet/overlays90-minute target union. - timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http"]'), matrix.target) && 90 || 45 }} + timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index afef1f90a..001a41c9e 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -353,3 +353,20 @@ This source proof cannot qualify a newer wallet head: its owner must compare the full pinned inventory/configuration on the final source before adoption. Registry, runtime, assertions, workers, deadlines and thresholds remain owned and unchanged by the partition facility. + +### Remote reader execution allowance + +The complete `wallet-snapshot-remote-reader` campaign at wallet source +`de75e1d5150f5f0dda4e81e6631e55c17d5f8a30` passed in 2,691.70 seconds: +1,248 mutants, 943 killed, 235 timed out and 70 survived, with zero uncovered +or invalid outcomes and a 94.39% global score. All 785 source/test files and +13 configuration inputs stayed fixed. Worker exits were recovered by the +runner and remain in the raw log; their native cause is unconfirmed. + +That local execution leaves only 8.30 seconds of the former 45-minute hosted +job allowance for installation, build restoration and reporting. The reader +therefore joins the existing bounded 90-minute allowance in both workflows. +All prior allowances and the 45-minute default remain; source/test unions, +property settings, workers and the original global gates are unchanged. Record +actual hosted setup/execution overhead before drawing performance conclusions. +This is an execution allowance, not a measured speedup or full #544 acceptance. diff --git a/scripts/ci-integration/mutation-workflows.test.mjs b/scripts/ci-integration/mutation-workflows.test.mjs index 494803312..dcd5eaf6a 100644 --- a/scripts/ci-integration/mutation-workflows.test.mjs +++ b/scripts/ci-integration/mutation-workflows.test.mjs @@ -140,6 +140,7 @@ test('full campaign receipts cannot borrow another attempt or a partial manual t 'wallet-snapshot-sync', 'wallet-snapshot-sync-destination', 'wallet-snapshot-sync-rows', + 'wallet-snapshot-remote-reader', 'root-eviction-journal', 'root-eviction-records' ]) diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 567a0b95a..47485f041 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -27,7 +27,7 @@ function workflowJobBlocks(workflow) { function assertWalletMutationTimeout(job, defaultMinutes) { const targets = - '["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http"]' + '["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader"]' const expected = ` timeout-minutes: \${{ contains(fromJSON('${targets}'), matrix.target) && 90 || ${defaultMinutes} }}` assert.equal(job.source.match(/^ timeout-minutes: .+$/m)?.[0], expected) } From 400b58c11a04ac8e4c90a32bf5dedaca548e8f92 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 21:32:02 -0700 Subject: [PATCH 061/127] Retain snapshot source ownership until physical cleanup --- docs/guides/wallet-sync-reliability.md | 31 ++++ docs/reference/package-api-migrations.md | 76 +++++----- governance/mutation-testing/policy.json | 2 +- governance/mutation-testing/targets.mjs | 4 +- governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 7 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 6 + packages/wallet/wallet-toolbox/README.md | 6 + .../src/storage/schema/KnexMigrations.ts | 12 ++ .../schema/snapshotArchiveOwnerMigration.ts | 26 ++++ .../ConcurrentSnapshotArchiveSource.test.ts | 2 +- .../KnexSnapshotArchiveCapture.test.ts | 8 +- .../KnexSnapshotArchiveRequestStore.test.ts | 142 +++++++++++++++++- .../KnexSnapshotArchiveRequestStore.ts | 51 ++++++- .../archive/KnexSnapshotArchiveRpc.test.ts | 37 ++--- .../archive/KnexSnapshotArchiveRpc.ts | 1 + .../KnexSnapshotArchiveService.test.ts | 69 ++++++++- .../archive/KnexSnapshotArchiveService.ts | 16 +- .../archive/KnexSnapshotArchiveStore.test.ts | 2 +- .../archive/KnexSnapshotArchiveStore.ts | 23 ++- .../snapshot/archive/RemoteSnapshotLease.ts | 33 ++-- .../archive/RemoteSnapshotPageReader.ts | 11 +- .../snapshot/archive/RemoteSnapshotRows.ts | 56 +++---- .../archive/SnapshotArchiveHttp.test.ts | 6 +- .../snapshot/archive/SnapshotArchiveOwner.ts | 48 ++++++ .../SnapshotArchiveService.property.test.ts | 93 +++++++++++- .../snapshot/archive/openRemoteSnapshot.ts | 36 +++-- .../wallet-toolbox/test/consumer/hashWasm.cts | 5 +- .../wallet-toolbox/test/consumer/hashWasm.mts | 5 +- .../test/storage/snapshotArchiveMysql.cjs | 119 +++++++++++++-- .../utils/remoteSnapshotReaderFixtures.ts | 9 +- specs/wallet/sync-portability-program.md | 9 ++ 32 files changed, 788 insertions(+), 167 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveOwnerMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index ee9a1abd6..2a8dd8b1c 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -675,3 +675,34 @@ admission and staging without proving that the original replica has physically drained its SQL reader. Owner recovery and bounded driver/query cleanup remain open requirements before server advertisement and program completion. No claim of a distributed physical-pool ceiling follows from logical quota accounting. + +## Durable source cleanup fence (unadvertised implementation) + +The additive `2026-10-01-001 add snapshot archive source owners` migration +registers each service capture's exact internal claim in one of eight shared +slots before source-pool acquisition. The request and its archive remain charged +until the owning controller acknowledges awaited source and pool cleanup. Ready +publication requires that acknowledgement too. Another controller may cancel or +expire the request, but cannot free its pages, logical reservation or slot while +the owner record remains. Each append checks the durable request fence in the +same transaction as its page write. Direct archive close and expiry cleanup obey +the same owner fence; a pending source does not prevent cleanup of other archives. + +A pending close raises `SnapshotArchiveCleanupPendingError`; it does not return +successful cancellation. A failed local cleanup fences that controller's +admission. Repeating an acknowledgement is idempotent and an old or incorrect +claim cannot release a successor. The owner migration refuses removal while +owners remain. Run migrations before admitting captures, keep all serving +binaries on the same candidate, and stop and drain captures before any downgrade. +Older draft binaries do not enforce this new table; mixed-version capture is +unsupported. Standard wallet tables and BRC-38/39 bytes are unchanged. + +This checkpoint deliberately retains ownership after an unproved process loss. +Backend-bound orphan recovery and bounded client polling for pending cleanup are +still required before reader advertisement. An elapsed lease is a fence, not +proof that an old SQL operation stopped. The eight logical slots do not establish +a global physical-connection ceiling; per-provider physical admission remains +occupied until its acquisition and cleanup settle. Actual deployment replica and +driver limits require separate qualification. The generated two-connection +lifecycle suite exercises cancellation, repeated status/reaping, incorrect and +exact acknowledgements, and preserved reservations across at least 300 schedules. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index b05b904ba..241930d46 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Orphan recovery and pending-cleanup client polling remain incomplete; reader advertisement stays disabled. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 4cad363e3..7c2de869d 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -122,7 +122,7 @@ "manifest": "packages/wallet/wallet-toolbox/package.json", "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts", "risk": "critical", - "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation and cleanup lifecycle", + "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation, exact source ownership and cleanup lifecycle", "minimumScore": 90, "maximumNoCoverage": 0, "maximumInvalid": 0 diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 326dcfd21..9f02721a4 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -485,7 +485,9 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts', 'src/storage/snapshot/archive/KnexSnapshotArchiveService.ts', 'src/storage/snapshot/archive/SnapshotArchiveSql.ts', - 'src/storage/schema/snapshotArchiveRequestMigration.ts' + 'src/storage/schema/snapshotArchiveRequestMigration.ts', + 'src/storage/snapshot/archive/SnapshotArchiveOwner.ts', + 'src/storage/schema/snapshotArchiveOwnerMigration.ts' ], ...jestTarget( 'jest.config.cjs', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index b7bac7aa4..bba5cf344 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,8 +217,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Orphan recovery and pending-cleanup client polling remain incomplete; reader advertisement stays disabled.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 96640654f..cde5a75f3 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -540,13 +540,14 @@ "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts", "manifest": "packages/wallet/wallet-toolbox/package.json", "risk": "critical", - "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation and cleanup lifecycle", - "target": "Generated SQL claim, retry, capture publication and terminal cleanup schedules", + "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation, exact source ownership and cleanup lifecycle", + "target": "Generated SQL claim, retry, capture publication, cross-controller cancellation and exact-owner cleanup schedules", "invariants": [ "A retry returns the original request and immutable ready archive without a second admission or writer token.", "Pending requests reserve the same shared capacity used by local archives before source acquisition.", "Archive assignment and ready receipt publication commit atomically.", - "Terminal requests retain bounded receipts and release physical capacity exactly once." + "Remote cancellation, repeated reaping and incorrect acknowledgements retain pages and quota while the exact source owner remains.", + "Terminal requests retain bounded receipts and release their logical reservation exactly once after the source owner acknowledges physical cleanup." ] }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 07adee2aa..d80f17f70 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,12 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add an exact-claim source-owner fence and additive owner migration. Remote + cancellation cannot release archive/request capacity or publish ready before + the owning source and pool have closed. Append checks cancellation atomically; + direct archive cleanup follows the same fence. Orphan recovery and pending + cleanup polling remain incomplete; reader advertisement stays disabled. + - Add the unadvertised remote row-reader foundation: immutable server-issued offers, exact-request retry, fixed leases, verified packed rows and durable cursor integration with local sync. Reader advertisement stays disabled while diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 12632bd45..ddab49e88 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -68,6 +68,12 @@ and both client variants, with bounded responses and durable admission/status. ordinary sync/export adoption and portable validation remain incomplete. Server or client `snapshotArchives: false`, or provider `snapshotSync: false`, disables this capability. See the [transport contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#authenticated-snapshot-archive-transport-unpublished-candidate). +The candidate's additive source-owner table now retains request/archive capacity +until the exact controller acknowledges physical source cleanup. Cross-controller +cancellation fences the next page; pending cleanup reports an error and keeps its +reservation. Do not mix older candidate binaries or remove the owner schema while +captures remain. Backend-bound orphan recovery and client pending-cleanup polling +are still incomplete, and the server reader capability remains unadvertised. The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index e5a3e3705..c0e90767c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,8 @@ +import { + addSnapshotArchiveOwnerTable, + removeSnapshotArchiveOwnerTable, + SNAPSHOT_ARCHIVE_OWNER_MIGRATION +} from './snapshotArchiveOwnerMigration' import { addSnapshotArchiveRequestTable, removeSnapshotArchiveRequestTable, @@ -22,6 +27,7 @@ import { LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS } from '../methods/managedChangePolicy' +export { SNAPSHOT_ARCHIVE_OWNER_MIGRATION } from './snapshotArchiveOwnerMigration' export { SNAPSHOT_ARCHIVE_REQUEST_MIGRATION } from './snapshotArchiveRequestMigration' export { SNAPSHOT_ARCHIVE_MIGRATION } from './snapshotArchiveMigration' export { SNAPSHOT_SYNC_MIGRATION } from './snapshotSyncMigration' @@ -108,6 +114,12 @@ export class KnexMigrations implements MigrationSource { } } + migrations[SNAPSHOT_ARCHIVE_OWNER_MIGRATION] = { + config: { transaction: true }, + up: addSnapshotArchiveOwnerTable, + down: removeSnapshotArchiveOwnerTable + } + migrations[SNAPSHOT_ARCHIVE_REQUEST_MIGRATION] = { config: { transaction: true }, up: addSnapshotArchiveRequestTable, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveOwnerMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveOwnerMigration.ts new file mode 100644 index 000000000..296b2d273 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveOwnerMigration.ts @@ -0,0 +1,26 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' + +export const SNAPSHOT_ARCHIVE_OWNER_MIGRATION = '2026-10-01-001 add snapshot archive source owners' + +export async function addSnapshotArchiveOwnerTable(knex: Knex): Promise { + if (!(await knex.schema.hasTable('snapshot_archive_owners'))) { + await knex.schema.createTable('snapshot_archive_owners', table => { + table.integer('slot').primary() + table.string('identityKey', 130).notNullable() + table.string('requestId', 64).notNullable() + table.string('claimToken', 64).notNullable() + table.string('archiveId', 64).nullable().unique() + table.unique(['identityKey', 'requestId']) + }) + } +} + +export async function removeSnapshotArchiveOwnerTable(knex: Knex): Promise { + if (await knex.schema.hasTable('snapshot_archive_owners')) { + if ((await knex('snapshot_archive_owners').first('slot')) !== undefined) { + throw new WERR_INVALID_OPERATION('Drain snapshot archive sources before removing their schema') + } + } + await knex.schema.dropTableIfExists('snapshot_archive_owners') +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts index c9b7a46b8..4de0a487b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -60,7 +60,7 @@ test('a ready request waits for owned reader destruction while foreground storag const source = (await storage.openSnapshotArchiveSource(identity))! expect(source.user.identityKey).toBe(identity) expect(source.sourceStorage.storageIdentityKey).toBe('original-source') - expect(source.sourceSchema).toBe('2026-09-30-003 add snapshot archive requests') + expect(source.sourceSchema).toBe('2026-10-01-001 add snapshot archive source owners') const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex expect(reader.knex).not.toBe(storage.knex) expect(reader.knex.client.config.pool).toMatchObject({ min: 0, max: 1 }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index fe1c543aa..33d64ad26 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' -import { SNAPSHOT_ARCHIVE_REQUEST_MIGRATION } from '../../schema/KnexMigrations' +import { SNAPSHOT_ARCHIVE_OWNER_MIGRATION } from '../../schema/KnexMigrations' import { decodeSyncTransfer } from '../../remoting/SyncTransfer' import * as Transfer from '../../remoting/SyncTransfer' import * as ArchiveSource from './KnexSnapshotArchiveSource' @@ -72,7 +72,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { onProgress: p => progress.push(p) }) - expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_ARCHIVE_REQUEST_MIGRATION) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_ARCHIVE_OWNER_MIGRATION) expect(manifest.binding.sourceStorage.storageName).toBe('original source') expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) @@ -84,7 +84,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof sourceStorageIdentityKey: 'original-source', archiveId: manifest.archiveId, digest: manifest.digest, - sourceSchema: SNAPSHOT_ARCHIVE_REQUEST_MIGRATION + sourceSchema: SNAPSHOT_ARCHIVE_OWNER_MIGRATION }) expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} @@ -153,7 +153,7 @@ test('source schema, primary history and closure stay pinned while an independen await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) - expect(source.sourceSchema).toBe(SNAPSHOT_ARCHIVE_REQUEST_MIGRATION) + expect(source.sourceSchema).toBe(SNAPSHOT_ARCHIVE_OWNER_MIGRATION) expect(source.user.activeStorage).toBe(originalPrimary) await expect(source.validateClosure()).resolves.toBeUndefined() expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts index 0ad6b7a17..2843f2121 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts @@ -1,3 +1,8 @@ +import { + addSnapshotArchiveOwnerTable, + removeSnapshotArchiveOwnerTable +} from '../../schema/snapshotArchiveOwnerMigration' +import { SnapshotArchiveCleanupPendingError } from './SnapshotArchiveOwner' import { snapshotArchiveReaderRequestId } from './SnapshotArchiveReaderRequest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -35,7 +40,7 @@ const binding: SnapshotArchiveBinding = { const databases: Knex[] = [] const directories: string[] = [] -async function fixture() { +async function fixture(requireSourceDrain = false) { const directory = await mkdtemp(join(tmpdir(), 'snapshot-request-')) directories.push(directory) const open = () => { @@ -53,12 +58,13 @@ async function fixture() { await db.raw('PRAGMA journal_mode = WAL') await addSnapshotArchiveTables(db) await addSnapshotArchiveRequestTable(db) + if (requireSourceDrain) await addSnapshotArchiveOwnerTable(db) const peer = open() return { db, peer, - requests: new KnexSnapshotArchiveRequestStore(db), - second: new KnexSnapshotArchiveRequestStore(peer), + requests: new KnexSnapshotArchiveRequestStore(db, requireSourceDrain), + second: new KnexSnapshotArchiveRequestStore(peer, requireSourceDrain), archives: new KnexSnapshotArchiveStore(db) } } @@ -659,3 +665,133 @@ test('early reader collection refuses altered persisted tuple binding and rolls expect(await db('snapshot_archive_requests')).toHaveLength(0) expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) }) + +test.each(['cancel', 'expiry'] as const)( + '%s cannot release another source owner before its physical acknowledgement', + async reason => { + const { db, requests, second } = await fixture(true) + const input = request() + const { owner } = await requests.claim(identity, input) + expect(await db('snapshot_archive_owners')).toEqual([{ slot: 0, ...owner, archiveId: null }]) + await expect(removeSnapshotArchiveOwnerTable(db)).rejects.toThrow('Drain snapshot archive sources') + if (reason === 'expiry') jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(input.notAfter) + await expect( + second.close(identity, input.requestId, reason === 'expiry' ? 'expired' : 'closed') + ).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + await second.reap() + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + await requests.sourceClosed({ ...owner!, claimToken: 'f'.repeat(64) }) + expect(await db('snapshot_archive_owners')).toHaveLength(1) + await expect(second.close(identity, input.requestId)).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + // The exact owner's acknowledgement remains valid after expiry. + await requests.sourceClosed(owner!) + await requests.sourceClosed(owner!) + await second.close(identity, input.requestId) + expect(await db('snapshot_archive_owners')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + await removeSnapshotArchiveOwnerTable(db) + expect(await db.schema.hasTable('snapshot_archive_owners')).toBe(false) + } +) + +test('request cancellation fences the next atomic append and direct archive reaping cannot bypass source ownership', async () => { + const { db, peer, requests, second, archives } = await fixture(true) + const input = request() + const { owner } = await requests.claim(identity, input) + const writer = await requests.begin(owner!, binding) + const page = { sequence: 0, table: snapshotArchiveTables[0], rows: 0, done: true, bytes: Uint8Array.of(7) } + const mutableWriter = { ...writer } + const appended = requests.append(owner!, mutableWriter, page) + mutableWriter.archiveId = 'f'.repeat(64) + page.bytes[0] = 9 + await appended + expect(new Uint8Array((await db('snapshot_archive_pages').first()).payload)).toEqual(Uint8Array.of(7)) + expect(await db('snapshot_archive_owners').first()).toMatchObject({ ...owner, archiveId: writer.archiveId }) + await second.markCancellation(identity, input) + await expect( + requests.append(owner!, writer, { ...page, sequence: 1, table: snapshotArchiveTables[1] }) + ).rejects.toThrow('unavailable') + await expect(new KnexSnapshotArchiveStore(peer).close(identity, writer.archiveId)).rejects.toBeInstanceOf( + SnapshotArchiveCleanupPendingError + ) + await second.reap() + await archives.reap() + expect(await db('snapshot_archive_pages')).toHaveLength(1) + expect(await db('snapshot_archives').first()).toMatchObject({ state: 'closing' }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + await requests.sourceClosed(owner!) + await second.close(identity, input.requestId) + await second.close(identity, input.requestId) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('a guarded request cannot publish a ready receipt until its source is physically acknowledged closed', async () => { + const { db, requests, second, archives } = await fixture(true) + const input = request() + const { owner } = await requests.claim(identity, input) + const writer = await requests.begin(owner!, binding) + for (const [sequence, table] of snapshotArchiveTables.entries()) + await requests.append(owner!, writer, { sequence, table, rows: 0, done: true, bytes: Uint8Array.of(1) }) + await expect(requests.seal(owner!, writer)).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + expect((await second.status(identity, input.requestId)).state).toBe('building') + await expect(archives.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') + await requests.sourceClosed(owner!) + const manifest = await requests.seal(owner!, writer) + expect(await second.status(identity, input.requestId)).toMatchObject({ + state: 'ready', + archiveId: writer.archiveId, + digest: manifest.digest + }) + expect(await db('snapshot_archive_owners')).toHaveLength(0) + await second.close(identity, input.requestId) +}) + +test('an occupied source-owner table refuses admission even if its capacity ledger was independently damaged', async () => { + const { db, requests } = await fixture(true) + await db('snapshot_archive_owners').insert( + Array.from({ length: 8 }, (_, slot) => ({ + slot, + identityKey: other, + requestId: slot.toString(16).padStart(64, '0'), + claimToken: 'e'.repeat(64), + archiveId: null + })) + ) + await expect(requests.claim(identity, request())).rejects.toThrow('source capacity is occupied') + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + expect(await db('snapshot_archive_owners')).toHaveLength(8) +}) + +test('source slots are reused only after acknowledgement and stale owners cannot release a successor', async () => { + const { db, requests } = await fixture(true) + const first = request() + const firstClaim = await requests.claim(identity, first) + await requests.sourceClosed(firstClaim.owner!) + await requests.close(identity, first.requestId) + const next = request('c'.repeat(64)) + const nextClaim = await requests.claim(identity, next) + expect(await db('snapshot_archive_owners').first()).toMatchObject({ slot: 0, ...nextClaim.owner }) + await requests.sourceClosed(firstClaim.owner!) + await expect(requests.close(identity, next.requestId)).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + expect((await db('snapshot_archive_capacity').first()).archives).toBe(1) + await requests.sourceClosed(nextClaim.owner!) + await requests.close(identity, next.requestId) + expect((await db('snapshot_archive_capacity').first()).archives).toBe(0) +}) + +test('source-owner schema creation and removal are idempotent without discarding occupied slots', async () => { + const { db, requests } = await fixture(true) + const input = request() + const { owner } = await requests.claim(identity, input) + const rows = await db('snapshot_archive_owners') + await addSnapshotArchiveOwnerTable(db) + expect(await db('snapshot_archive_owners')).toEqual(rows) + await requests.sourceClosed(owner!) + await requests.close(identity, input.requestId) + await removeSnapshotArchiveOwnerTable(db) + await removeSnapshotArchiveOwnerTable(db) + expect(await db.schema.hasTable('snapshot_archive_owners')).toBe(false) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts index 4391f6c3d..35511254c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts @@ -1,3 +1,10 @@ +import { + reserveSnapshotArchiveOwner, + assignSnapshotArchiveOwner, + releaseSnapshotArchiveOwner, + hasSnapshotArchiveOwner, + SnapshotArchiveCleanupPendingError +} from './SnapshotArchiveOwner' import { Random, Utils } from '@bsv/sdk' import type { Knex } from 'knex' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' @@ -16,7 +23,8 @@ import { snapshotArchiveLimits, type SnapshotArchiveBinding, type SnapshotArchiveWriter, - type SnapshotArchiveManifest + type SnapshotArchiveManifest, + type SnapshotArchivePage } from './SnapshotArchive' import { lockSnapshotArchiveCapacity, snapshotArchiveDatabaseNow } from './SnapshotArchiveSql' import { @@ -61,7 +69,10 @@ function identifier(value: string): void { /** Durable deduplication with admission charged before opening a source pool. */ export class KnexSnapshotArchiveRequestStore { - constructor(private readonly knex: Knex) {} + constructor( + private readonly knex: Knex, + private readonly requireSourceDrain = false + ) {} private async receipt(k: Knex, row: RequestRow): Promise { const base = { version: 1 as const, requestId: row.requestId, expiresAt: Number(row.expiresAt) } @@ -191,6 +202,7 @@ export class KnexSnapshotArchiveRequestStore { } if (reader) await trx(table).where({ identityKey, requestId: request.requestId }).update(row) else await trx(table).insert(row) + if (this.requireSourceDrain) await reserveSnapshotArchiveOwner(trx, owner) await trx('snapshot_archive_capacity') .where({ id: 1 }) .update({ archives: capacity.archives + 1, reservedBytes: Number(capacity.reservedBytes) + request.maxBytes }) @@ -234,11 +246,35 @@ export class KnexSnapshotArchiveRequestStore { lifetimeMs: remaining }) await trx('snapshot_archives').where({ archiveId: writer.archiveId }).update({ expiresAt: row.expiresAt }) + await assignSnapshotArchiveOwner(trx, claim, writer.archiveId) await trx(table).where(claim).update({ state: 'capturing', archiveId: writer.archiveId }) return writer }) } + /** A remote cancellation fences the next append in its atomic archive transaction. */ + async append( + owner: SnapshotArchiveRequestOwner, + writer: SnapshotArchiveWriter, + page: Omit + ): Promise { + const claim = { ...owner } + const captured = { ...writer } + await new KnexSnapshotArchiveStore(this.knex).append(captured, page, async trx => { + const row = await this.owned(trx, claim) + if (row.state !== 'capturing' || row.archiveId !== captured.archiveId) unavailable() + }) + } + + /** Called only after the local source and its owned pool have physically closed. */ + async sourceClosed(owner: SnapshotArchiveRequestOwner): Promise { + const claim = { ...owner } + await this.knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + await releaseSnapshotArchiveOwner(trx, claim) + }) + } + async seal(owner: SnapshotArchiveRequestOwner, writer: SnapshotArchiveWriter): Promise { const claim = { ...owner } const captured = { ...writer } @@ -246,6 +282,8 @@ export class KnexSnapshotArchiveRequestStore { await lockSnapshotArchiveCapacity(trx) const row = await this.owned(trx, claim) if (!['capturing', 'ready'].includes(row.state) || row.archiveId !== captured.archiveId) unavailable() + if (await hasSnapshotArchiveOwner(trx, { identityKey: row.identityKey, requestId: row.requestId })) + throw new SnapshotArchiveCleanupPendingError() const manifest = await new KnexSnapshotArchiveStore(trx).seal(captured) await trx(table).where(claim).update({ state: 'ready' }) return manifest @@ -347,6 +385,7 @@ export class KnexSnapshotArchiveRequestStore { await this.collectReader(trx, row) return undefined } + if (await hasSnapshotArchiveOwner(trx, { identityKey, requestId })) throw new SnapshotArchiveCleanupPendingError() if (row.archiveId !== null) return row.archiveId await trx('snapshot_archive_capacity') .where({ id: 1 }) @@ -377,8 +416,12 @@ export class KnexSnapshotArchiveRequestStore { }) .limit(snapshotArchiveRequestLimits.total) await runInSeries(rows, async row => { - if (Number(row.expiresAt) <= now) await this.close(row.identityKey, row.requestId, 'expired') - else await this.release(row.identityKey, row.requestId) + try { + if (Number(row.expiresAt) <= now) await this.close(row.identityKey, row.requestId, 'expired') + else await this.release(row.identityKey, row.requestId) + } catch (error) { + if (!(error instanceof SnapshotArchiveCleanupPendingError)) throw error + } }) await this.knex.transaction(async trx => { await lockSnapshotArchiveCapacity(trx) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts index 8b5e7b001..1d90020da 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts @@ -5,24 +5,27 @@ import { gate, snapshotHttpFixture } from '../../../../test/utils/snapshotArchiv afterEach(() => jest.restoreAllMocks()) -test.each(['snapshot_archive_requests', 'snapshot_archives', 'snapshot_archive_pages', 'snapshot_archive_capacity'])( - 'declines capability when migration table %s is missing', - async table => { - const fixture = await snapshotHttpFixture() - const rpc = new KnexSnapshotArchiveRpc(fixture.storage) - try { - expect(await rpc.capabilities()).toEqual(snapshotArchiveCapabilities) - await fixture.storage.knex.schema.dropTable(table) - expect(await rpc.capabilities()).toBeUndefined() - await expect( - rpc.dispatch('getSnapshotArchiveOffer', [{ version: 1, identityKey: fixture.identityKey }], fixture.identityKey) - ).rejects.toThrow('unavailable') - } finally { - await rpc.close() - await fixture.close() - } +test.each([ + 'snapshot_archive_requests', + 'snapshot_archive_owners', + 'snapshot_archives', + 'snapshot_archive_pages', + 'snapshot_archive_capacity' +])('declines capability when migration table %s is missing', async table => { + const fixture = await snapshotHttpFixture() + const rpc = new KnexSnapshotArchiveRpc(fixture.storage) + try { + expect(await rpc.capabilities()).toEqual(snapshotArchiveCapabilities) + await fixture.storage.knex.schema.dropTable(table) + expect(await rpc.capabilities()).toBeUndefined() + await expect( + rpc.dispatch('getSnapshotArchiveOffer', [{ version: 1, identityKey: fixture.identityKey }], fixture.identityKey) + ).rejects.toThrow('unavailable') + } finally { + await rpc.close() + await fixture.close() } -) +}) test('unsupported source and a close during capability probing never advertise or open a pool', async () => { const fixture = await snapshotHttpFixture() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts index a905197e1..de82bbf36 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts @@ -30,6 +30,7 @@ export class KnexSnapshotArchiveRpc { return undefined for (const name of [ 'snapshot_archive_requests', + 'snapshot_archive_owners', 'snapshot_archives', 'snapshot_archive_pages', 'snapshot_archive_capacity' diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts index af5c7514b..4c5023159 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts @@ -1,3 +1,4 @@ +import { SnapshotArchiveCleanupPendingError } from './SnapshotArchiveOwner' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -55,6 +56,15 @@ async function fixture() { await seedArchiveClosure(storage, user.userId, peer.userId) return { storage, controller: service(storage), open } } +async function closeRepairedSource(storage: StorageKnex, requestId: string): Promise { + const requests = new KnexSnapshotArchiveRequestStore(storage.knex) + await expect(requests.close(identity, requestId, 'failed')).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + const owner = await storage.knex('snapshot_archive_owners').where({ identityKey: identity, requestId }).first() + expect(owner).toBeDefined() + await requests.sourceClosed({ identityKey: identity, requestId, claimToken: owner.claimToken }) + await requests.close(identity, requestId, 'failed') + expect(await storage.knex('snapshot_archive_owners')).toHaveLength(0) +} function request(nonce = 'b'.repeat(64)) { const fields = { version: 1 as const, nonce, notAfter: Date.now() + 300000, maxBytes: 32768 } return { ...fields, requestId: snapshotArchiveRequestId(fields) } @@ -255,7 +265,7 @@ test('failed physical cleanup retains the reservation and fences the controller expect(() => controller.create(identity, request('c'.repeat(64)))).toThrow('closed') await expect(controller.close()).rejects.toBe(failure) await source!.close() - await new KnexSnapshotArchiveRequestStore(storage.knex).close(identity, input.requestId, 'failed') + await closeRepairedSource(storage, input.requestId) }) test('unsupported and busy sources fail without disturbing an existing local source', async () => { @@ -339,7 +349,7 @@ test('failed cleanup while opening retains admission even though no source was r jest.restoreAllMocks() const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex await reader.destroy() - await new KnexSnapshotArchiveRequestStore(storage.knex).close(identity, input.requestId, 'failed') + await closeRepairedSource(storage, input.requestId) }) test('start returns a durable receipt before capture completes and repeats only that admission', async () => { @@ -594,3 +604,58 @@ test.each(['failed', 'resource-limited'] as const)( await expect(replacement.admitReader(identity, offered.request)).rejects.toThrow('unavailable') } ) + +test('cancellation through another controller retains quota until the capturing owner drains its physical pool', async () => { + const { storage, controller, open } = await fixture() + const replacementStorage = open() + await replacementStorage.makeAvailable() + const replacement = service(replacementStorage) + const input = request() + const reading = gate() + const allowRead = gate() + const destroying = gate() + const allowDestroy = gate() + const original = storage.openSnapshotArchiveSource.bind(storage) + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options) => { + const source = (await original(key, options))! + const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex + const destroy = reader.destroy.bind(reader) + jest.spyOn(reader, 'destroy').mockImplementation(async () => { + destroying.resolve() + await allowDestroy.promise + await destroy() + }) + return { + ...source, + readPage: async (...args) => { + reading.resolve() + await allowRead.promise + return await source.readPage(...args) + } + } + }) + const capture = controller.create(identity, input) + void capture.catch(() => undefined) + try { + await reading.promise + await expect(replacement.cancelRequest(identity, input)).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + expect((await replacement.status(identity, input.requestId)).state).toBe('closed') + expect(await storage.knex('snapshot_archive_owners')).toHaveLength(1) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + allowRead.resolve() + await destroying.promise + await expect(replacement.cancelRequest(identity, input)).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + expect(await storage.knex('snapshot_archive_pages')).toHaveLength(0) + await storage.knex('tx_labels').where({ txLabelId: 1 }).update({ label: 'foreground while owner drains' }) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + allowDestroy.resolve() + await expect(capture).rejects.toThrow('unavailable') + await replacement.cancelRequest(identity, input) + expect(await storage.knex('snapshot_archive_owners')).toHaveLength(0) + expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + } finally { + allowRead.resolve() + allowDestroy.resolve() + await capture.catch(() => undefined) + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts index 0c738703b..c2d0d4ae5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts @@ -36,7 +36,7 @@ export class KnexSnapshotArchiveService { private cleanupFailure?: { error: unknown } constructor(private readonly storage: StorageKnex) { - this.requests = new KnexSnapshotArchiveRequestStore(storage.knex) + this.requests = new KnexSnapshotArchiveRequestStore(storage.knex, true) this.archives = new KnexSnapshotArchiveStore(storage.knex) } @@ -145,12 +145,14 @@ export class KnexSnapshotArchiveService { ): Promise { const { identityKey, request, controller } = job if (claimed.owner === undefined) return claimed.receipt + const owner = claimed.owner let source: SnapshotArchiveSource | undefined const cleanup = async (error?: unknown): Promise => { if (job.terminal !== 'closed' && error instanceof SnapshotResourceLimitError) job.terminal = 'resource-limited' try { // Keep the logical reservation through this process's physical cleanup. await source?.close() + await this.requests.sourceClosed(owner) await this.requests.close(identityKey, request.requestId, job.terminal) } catch (error) { this.failedCleanup(error) @@ -165,13 +167,15 @@ export class KnexSnapshotArchiveService { lifetimeMs: Math.min(300000, remaining) }) if (source === undefined) throw new WERR_NOT_IMPLEMENTED('Snapshot archive capture requires SQLite WAL or MySQL') - const owner = claimed.owner await captureSnapshotArchiveSource( source, { begin: binding => this.requests.begin(owner, binding), - append: (writer, page) => this.archives.append(writer, page), - seal: writer => this.requests.seal(owner, writer), + append: (writer, page) => this.requests.append(owner, writer, page), + seal: async writer => { + await this.requests.sourceClosed(owner) + return await this.requests.seal(owner, writer) + }, close: (_identityKey, _archiveId, error) => cleanup(error) }, identityKey, @@ -215,8 +219,8 @@ export class KnexSnapshotArchiveService { await this.requests.markCancellation(identityKey, request) const job = this.active if (job?.identityKey === identityKey && job.request.requestId === request.requestId) await this.stop(job) - // markCancellation does not release an existing physical owner's reservation. - // stop drains this process first; then the normal cleanup path is resumable. + // A different replica can fence the request, but only proved source cleanup + // permits release. Its pending outcome must not be acknowledged as complete. await this.requests.close(identityKey, request.requestId) } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index d43373639..62aaa76ac 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -631,7 +631,7 @@ test('only acknowledged sequence positions are readable, even if an unacknowledg test('the auxiliary migration is registered after the durable sync schema', async () => { const migrations = new KnexMigrations('test', 'source', 'source', 1024) - expect(await migrations.getLatestMigration()).toBe('2026-09-30-003 add snapshot archive requests') + expect(await migrations.getLatestMigration()).toBe('2026-10-01-001 add snapshot archive source owners') }) test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts index 6bc9e925f..7813bbdef 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts @@ -1,3 +1,4 @@ +import { hasSnapshotArchiveOwner, SnapshotArchiveCleanupPendingError } from './SnapshotArchiveOwner' import { Hash, Random, Utils } from '@bsv/sdk' import type { Knex } from 'knex' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' @@ -183,7 +184,11 @@ export class KnexSnapshotArchiveStore { return row } - async append(writer: SnapshotArchiveWriter, page: Omit): Promise { + async append( + writer: SnapshotArchiveWriter, + page: Omit, + authorize?: (trx: Knex) => Promise + ): Promise { integer(page.sequence, 0, snapshotArchiveLimits.pages - 1, 'sequence') integer(page.rows, 0, snapshotArchiveLimits.rowsPerPage, 'rows') if ( @@ -201,6 +206,7 @@ export class KnexSnapshotArchiveStore { const digest = hash(input.bytes) await this.knex.transaction(async trx => { await this.capacity(trx) + await authorize?.(trx) const row = await this.ownedWriter(trx, owner) if (input.sequence < row.nextSequence) { const prior: PageRow | undefined = await trx('snapshot_archive_pages') @@ -346,11 +352,12 @@ export class KnexSnapshotArchiveStore { const found = await this.knex.transaction(async trx => { await this.capacity(trx) const row = await trx('snapshot_archives').where({ archiveId, identityKey }).first('archiveId') - if (row === undefined) return false + if (row === undefined) return 'absent' await trx('snapshot_archives').where({ archiveId, identityKey }).update({ state: 'closing' }) - return true + return (await hasSnapshotArchiveOwner(trx, { identityKey, archiveId })) ? 'pending' : 'ready' }) - if (!found) return + if (found === 'absent') return + if (found === 'pending') throw new SnapshotArchiveCleanupPendingError() // Bounded exact-key deletes do not hold source wallet locks or release the // capacity reservation early. A crash or concurrent closer can resume them. let hasPages = true @@ -397,6 +404,12 @@ export class KnexSnapshotArchiveStore { .orWhere({ state: 'closing' }) .orderBy('archiveId') .limit(snapshotArchiveLimits.archives) - await runInSeries(rows, row => this.close(row.identityKey, row.archiveId)) + await runInSeries(rows, async row => { + try { + await this.close(row.identityKey, row.archiveId) + } catch (error) { + if (!(error instanceof SnapshotArchiveCleanupPendingError)) throw error + } + }) } } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts index f35ef9368..e47c73499 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts @@ -6,6 +6,17 @@ import type { WalletReadSnapshotOptions } from '../WalletReadSnapshot' import { parseSnapshotArchiveReaderRequest, type SnapshotArchiveReaderRequest } from './SnapshotArchiveReaderRequest' import type { SnapshotArchiveTransport } from './SnapshotArchiveTransport' +function observedCompletion() { + let resolve!: () => void + let reject!: (error: unknown) => void + const closed = new Promise((onResolve, onReject) => { + resolve = onResolve + reject = onReject + }) + void closed.catch(() => undefined) + return { closed, resolve, reject } +} + /** Owns one opening/read operation and one expiry timer until remote cleanup settles. */ export class RemoteSnapshotLease { readonly lifetimeMs: number @@ -14,14 +25,14 @@ export class RemoteSnapshotLease { private readonly startedAt = performance.now() private readonly controller = new AbortController() private readonly signal: AbortSignal | undefined - private timer: ReturnType | undefined + private readonly timer: ReturnType | undefined private pending: Promise | undefined private request: Readonly | undefined private serverTime: number | undefined private reason: WERR_INVALID_OPERATION | undefined private closing: Promise | undefined - private resolveClosed!: () => void - private rejectClosed!: (error: unknown) => void + private readonly resolveClosed: () => void + private readonly rejectClosed: (error: unknown) => void constructor( private readonly transport: SnapshotArchiveTransport, @@ -32,11 +43,10 @@ export class RemoteSnapshotLease { throw new WERR_INVALID_PARAMETER('lifetimeMs', 'an integer from 1 to 3600000') this.expiresAt = Date.now() + this.lifetimeMs this.signal = options.signal - this.closed = new Promise((resolve, reject) => { - this.resolveClosed = resolve - this.rejectClosed = reject - }) - void this.closed.catch(() => undefined) + const completion = observedCompletion() + this.closed = completion.closed + this.resolveClosed = completion.resolve + this.rejectClosed = completion.reject this.signal?.addEventListener('abort', this.abort, { once: true }) if (this.signal?.aborted === true) this.abort() else @@ -128,7 +138,7 @@ export class RemoteSnapshotLease { async wait(milliseconds: number): Promise { await this.run( async signal => - await new Promise((resolve, reject) => { + await new Promise(resolve => { const finish = (): void => { signal.removeEventListener('abort', abort) resolve() @@ -136,9 +146,8 @@ export class RemoteSnapshotLease { const timer = setTimeout(finish, milliseconds) const abort = (): void => { clearTimeout(timer) - signal.removeEventListener('abort', abort) - // Only close() aborts this private signal, after setting its reason. - reject(this.reason) + // run() checks the recorded close reason after this wait settles. + finish() } // run() asserted openness immediately before this synchronous setup. signal.addEventListener('abort', abort, { once: true }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotPageReader.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotPageReader.ts index 76255e992..c784108d2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotPageReader.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotPageReader.ts @@ -107,6 +107,13 @@ function limit(value: number | undefined, fallback: number, ceiling: number, nam return result } +function fitsPage(rows: number, payloadBytes: number, charge: number, maxBytes: number): boolean { + if (payloadBytes + charge <= maxBytes) return true + if (rows === 0) + throw new SnapshotResourceLimitError('Snapshot row exceeds maxBytes; large-value streaming is required') + return false +} + /** One private decoded frame, plus returned rows charged to the caller's allocation budget. */ export function createRemoteSnapshotPageReader( transport: SnapshotArchiveTransport, @@ -192,9 +199,7 @@ export function createRemoteSnapshotPageReader( const receipt = directory.receipts[position.sequence] while (position.rowOffset < frame.rows.length && rows.length < maxRows) { const charge = frame.charges[position.rowOffset] - if (payloadBytes + charge > maxBytes) { - if (rows.length === 0) - throw new SnapshotResourceLimitError('Snapshot row exceeds maxBytes; large-value streaming is required') + if (!fitsPage(rows.length, payloadBytes, charge, maxBytes)) { full = true break } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.ts index 6bfdd0cd2..4a7db0778 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotRows.ts @@ -234,17 +234,20 @@ function record(value: unknown): Record { return Object.fromEntries(Object.entries(value)) } +function dateCell(value: unknown): Date { + if (typeof value !== 'string' || value.length > 32) invalid() + const date = new Date(value) + if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) invalid() + return date +} + function cell(value: unknown, kind: Kind): Cell { switch (kind) { case 'bytes': if (!(value instanceof Uint8Array)) invalid() return value - case 'date': { - if (typeof value !== 'string' || value.length > 32) invalid() - const date = new Date(value) - if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) invalid() - return date - } + case 'date': + return dateCell(value) case 'id': if (!Number.isSafeInteger(value) || (value as number) < 1) invalid() return value as number @@ -263,16 +266,18 @@ function cell(value: unknown, kind: Kind): Cell { } } +function fieldKind(schema: Schema, name: string): Kind | undefined { + if (Object.hasOwn(schema.required, name)) return schema.required[name] + if (schema.optional !== undefined && Object.hasOwn(schema.optional, name)) return schema.optional[name] + return undefined +} + function row(input: unknown, schema: Schema, userId: number): RemoteSnapshotRow { const fields = record(input) for (const name of Object.keys(schema.required)) if (!Object.hasOwn(fields, name)) invalid() const result: Record = {} for (const [name, value] of Object.entries(fields)) { - const kind = Object.hasOwn(schema.required, name) - ? schema.required[name] - : schema.optional !== undefined && Object.hasOwn(schema.optional, name) - ? schema.optional[name] - : undefined + const kind = fieldKind(schema, name) if (kind === undefined) invalid() Object.defineProperty(result, name, { value: cell(value, kind), enumerable: true }) } @@ -285,6 +290,12 @@ export interface RemoteSnapshotFrame { readonly charges: readonly number[] } +function cellCharge(entry: Cell): number { + if (typeof entry === 'string') return 64 + entry.length * 2 + if (entry instanceof Uint8Array) return 64 + entry.byteLength * 2 + return 64 +} + /** Decode one independently bounded frame only after its receipt hash was verified. */ export function decodeRemoteSnapshotFrame( bytes: Uint8Array, @@ -304,24 +315,17 @@ export function decodeRemoteSnapshotFrame( ) invalid() const rows = frame.rows.map(input => row(input, schemas[receipt.table], userId)) - const charges = rows.map(value => - Object.values(value).reduce( - (sum, entry) => - sum + - 64 + - (typeof entry === 'string' ? entry.length * 2 : entry instanceof Uint8Array ? entry.byteLength * 2 : 0), - 0 - ) - ) + const charges = rows.map(value => Object.values(value).reduce((sum, entry) => sum + cellCharge(entry), 0)) return { rows, charges } } +function detachCell(entry: Cell): Cell { + if (entry instanceof Uint8Array) return new Uint8Array(entry) + if (entry instanceof Date) return new Date(entry) + return entry +} + /** The cached frame remains private; callers own every returned mutable value. */ export function detachRemoteSnapshotRow(value: RemoteSnapshotRow): Record { - return Object.fromEntries( - Object.entries(value).map(([name, entry]) => [ - name, - entry instanceof Uint8Array ? new Uint8Array(entry) : entry instanceof Date ? new Date(entry.getTime()) : entry - ]) - ) + return Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, detachCell(entry)])) } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts index 63e284d93..9b5f19450 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts @@ -33,7 +33,7 @@ test.each([StorageClient, StorageMobile])( expect(storage.getSettings()).not.toHaveProperty('snapshotArchive') let transport = (await client.getSnapshotArchiveTransport(identityKey))! const offer = await transport.offer() - expect(offer.sourceSchema).toBe('2026-09-30-003 add snapshot archive requests') + expect(offer.sourceSchema).toBe('2026-10-01-001 add snapshot archive source owners') expect(Math.abs(offer.serverTime - Date.now())).toBeLessThan(5000) const fields = { version: 1 as const, @@ -132,9 +132,7 @@ test.each(['server-disabled', 'provider-disabled', 'small-response', 'old-schema expect((await client.findOrInsertUser(fixture.identityKey)).user.identityKey).toBe(fixture.identityKey) const rpc = Reflect.get(client, 'rpcCall').bind(client) await expect(rpc('getSnapshotArchiveOffer', [{ version: 1, identityKey: fixture.identityKey }])).rejects.toThrow( - variant === 'server-disabled' || variant === 'small-response' - ? 'network error 400' - : 'unavailable' + variant === 'server-disabled' || variant === 'small-response' ? 'network error 400' : 'unavailable' ) } finally { await fixture.close() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts new file mode 100644 index 000000000..76f86941b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts @@ -0,0 +1,48 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { SnapshotArchiveAdmissionLimitError } from './SnapshotArchiveAdmission' +import { snapshotArchiveLimits } from './SnapshotArchive' +import type { SnapshotArchiveRequestOwner } from './SnapshotArchiveRequest' + +const table = 'snapshot_archive_owners' + +/** The durable request is fenced, but its source has not proved cleanup yet. */ +export class SnapshotArchiveCleanupPendingError extends WERR_INVALID_OPERATION { + constructor() { + super('Snapshot archive source cleanup is pending') + } +} + +/** Call only inside the shared capacity-locked claim transaction. */ +export async function reserveSnapshotArchiveOwner(k: Knex, owner: SnapshotArchiveRequestOwner): Promise { + const rows: Array<{ slot: number }> = await k(table).select('slot').limit(snapshotArchiveLimits.archives) + const occupied = new Set(rows.map(row => row.slot)) + const slot = Array.from({ length: snapshotArchiveLimits.archives }, (_, index) => index).find( + index => !occupied.has(index) + ) + if (slot === undefined) throw new SnapshotArchiveAdmissionLimitError('Snapshot archive source capacity is occupied') + await k(table).insert({ slot, ...owner, archiveId: null }) +} + +/** Associate the source with its archive in the same transaction as begin(). */ +export async function assignSnapshotArchiveOwner( + k: Knex, + owner: SnapshotArchiveRequestOwner, + archiveId: string +): Promise { + if (await k.schema.hasTable(table)) await k(table).where(owner).update({ archiveId }) +} + +/** Local owner acknowledgement follows awaited physical source and pool cleanup. */ +export async function releaseSnapshotArchiveOwner(k: Knex, owner: SnapshotArchiveRequestOwner): Promise { + await k(table).where(owner).delete() +} + +/** Legacy local archive stores can precede this additive source-owner schema. */ +export async function hasSnapshotArchiveOwner( + k: Knex, + key: { identityKey: string; requestId: string } | { identityKey: string; archiveId: string } +): Promise { + if (!(await k.schema.hasTable(table))) return false + return (await k(table).where(key).first('slot')) !== undefined +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts index 432f68f3f..9aca4ef67 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts @@ -6,9 +6,11 @@ import { join } from 'node:path' import { knex } from 'knex' import { addSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' import { addSnapshotArchiveRequestTable } from '../../schema/snapshotArchiveRequestMigration' +import { addSnapshotArchiveOwnerTable } from '../../schema/snapshotArchiveOwnerMigration' +import { SnapshotArchiveCleanupPendingError } from './SnapshotArchiveOwner' import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' -import { snapshotArchiveTables, type SnapshotArchiveBinding } from './SnapshotArchive' +import { snapshotArchiveTables, type SnapshotArchiveBinding, type SnapshotArchiveWriter } from './SnapshotArchive' const MIN_PROPERTY_RUNS = 300 const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) @@ -20,6 +22,95 @@ fc.configureGlobal({ ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) }) +test('generated remote cancellation schedules retain source quota until exact cleanup acknowledgement', async () => { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-owner-property-')) + const open = () => + knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'owners.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const db = open() + const peer = open() + try { + await db.raw('PRAGMA journal_mode = WAL') + await addSnapshotArchiveTables(db) + await addSnapshotArchiveRequestTable(db) + await addSnapshotArchiveOwnerTable(db) + const requests = new KnexSnapshotArchiveRequestStore(db, true) + const replacement = new KnexSnapshotArchiveRequestStore(peer, true) + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: 13 }), + fc.array(fc.constantFrom('retry', 'reap', 'wrong-ack'), { minLength: 0, maxLength: 5 }), + fc.uint8Array({ minLength: 1, maxLength: 16 }), + async (pages, schedule, bytes) => { + const identityKey = '02' + bytes[0].toString(16).padStart(64, '0') + const issued = (await requests.offer(identityKey, { lifetimeMs: 300000, maxBytes: 32768 }))! + const { owner } = await requests.claimReader(identityKey, issued.request) + const date = new Date('2026-01-01T00:00:00.000Z') + let writer: SnapshotArchiveWriter | undefined + if (pages > 0) { + writer = await requests.begin(owner!, { + version: 1, + snapshotId: 'a'.repeat(64), + sourceSchema: 'owner-property-v1', + sourceStorage: { + created_at: date, + updated_at: date, + chain: 'test', + dbtype: 'SQLite', + storageIdentityKey: 'source', + storageName: '', + maxOutputScript: 1024 + }, + user: { created_at: date, updated_at: date, userId: 1, identityKey, activeStorage: 'source' } + }) + for (const [sequence, table] of snapshotArchiveTables.slice(0, pages).entries()) + await requests.append(owner!, writer, { sequence, table, rows: 0, done: true, bytes }) + } + await replacement.markReaderCancellation(identityKey, issued.request) + for (const action of schedule) { + if (action === 'retry') + expect((await requests.claimReader(identityKey, issued.request)).receipt.state).toBe('closed') + else if (action === 'reap') await replacement.reap() + else await replacement.sourceClosed({ ...owner!, claimToken: 'wrong-claim-token' }) + } + await expect(replacement.close(identityKey, issued.request.requestId)).rejects.toBeInstanceOf( + SnapshotArchiveCleanupPendingError + ) + expect(await db('snapshot_archive_owners')).toHaveLength(1) + expect(await db('snapshot_archive_pages')).toHaveLength(pages) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + if (writer !== undefined) + await expect( + requests.append(owner!, writer, { + sequence: 0, + table: snapshotArchiveTables[0], + rows: 0, + done: true, + bytes + }) + ).rejects.toThrow('unavailable') + await requests.sourceClosed(owner!) + await replacement.close(identityKey, issued.request.requestId) + await replacement.close(identityKey, issued.request.requestId) + await expect(requests.claimReader(identityKey, issued.request)).rejects.toThrow('unavailable') + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + expect(await db('snapshot_archive_owners')).toHaveLength(0) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + } + ) + ) + } finally { + await Promise.all([db.destroy(), peer.destroy()]) + await rm(directory, { recursive: true, force: true }) + } +}) + test('generated creation/retry/close schedules preserve request identity, atomic publication and exact capacity', async () => { const directory = await mkdtemp(join(tmpdir(), 'snapshot-request-property-')) const open = () => diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/openRemoteSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/openRemoteSnapshot.ts index 7a1777884..345d3abff 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/openRemoteSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/openRemoteSnapshot.ts @@ -1,4 +1,5 @@ import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' import type { WalletReadSnapshot, WalletReadSnapshotOptions } from '../WalletReadSnapshot' import { snapshotArchiveLimits } from './SnapshotArchive' import type { SnapshotArchiveRequestReceipt } from './SnapshotArchiveRequest' @@ -28,6 +29,26 @@ async function accepted( return result.outcome === 'accepted' ? result.receipt : undefined } +async function waitForCapture( + transport: SnapshotArchiveTransport, + lease: RemoteSnapshotLease, + request: SnapshotArchiveReaderRequest, + receipt: Readonly +): Promise> { + function* intervals() { + let interval = 100 + while (receipt.state === 'building') { + yield interval + interval = Math.min(interval * 2, 1000) + } + } + await runInSeries(intervals(), async interval => { + await lease.wait(interval) + receipt = await lease.runIdempotent(signal => transport.readerStatus(request, signal)) + }) + return receipt +} + /** Unsupported capacity may decline only before a source view is exposed. */ export async function openRemoteSnapshot( transport: SnapshotArchiveTransport, @@ -51,12 +72,7 @@ export async function openRemoteSnapshot( await lease.close() return undefined } - let interval = 100 - while (receipt.state === 'building') { - await lease.wait(interval) - receipt = await lease.runIdempotent(signal => transport.readerStatus(request, signal)) - interval = Math.min(interval * 2, 1000) - } + receipt = await waitForCapture(transport, lease, request, receipt) if (receipt.state === 'resource-limited') { await lease.close() return undefined @@ -67,8 +83,8 @@ export async function openRemoteSnapshot( const binding = directory.manifest.binding const copyDates = (value: T): T => ({ ...value, - created_at: new Date(value.created_at.getTime()), - updated_at: new Date(value.updated_at.getTime()) + created_at: new Date(value.created_at), + updated_at: new Date(value.updated_at) }) return Object.freeze({ version: 1 as const, @@ -90,8 +106,8 @@ export async function openRemoteSnapshot( } catch (error) { try { await lease.close() - } catch (cleanup) { - throw new RemoteSnapshotOpeningCleanupError(error, cleanup) + } catch (error_) { + throw new RemoteSnapshotOpeningCleanupError(error, error_) } throw error } diff --git a/packages/wallet/wallet-toolbox/test/consumer/hashWasm.cts b/packages/wallet/wallet-toolbox/test/consumer/hashWasm.cts index 545423e03..820ffd7e1 100644 --- a/packages/wallet/wallet-toolbox/test/consumer/hashWasm.cts +++ b/packages/wallet/wallet-toolbox/test/consumer/hashWasm.cts @@ -22,7 +22,4 @@ const encoded: Promise = hashWasm.argon2id({ hashLength: 32, outputType: 'encoded' }) -void hasImplicitAny -void original -void binary -void encoded +export { hasImplicitAny, original, binary, encoded } diff --git a/packages/wallet/wallet-toolbox/test/consumer/hashWasm.mts b/packages/wallet/wallet-toolbox/test/consumer/hashWasm.mts index 545423e03..820ffd7e1 100644 --- a/packages/wallet/wallet-toolbox/test/consumer/hashWasm.mts +++ b/packages/wallet/wallet-toolbox/test/consumer/hashWasm.mts @@ -22,7 +22,4 @@ const encoded: Promise = hashWasm.argon2id({ hashLength: 32, outputType: 'encoded' }) -void hasImplicitAny -void original -void binary -void encoded +export { hasImplicitAny, original, binary, encoded } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index 3c092be87..ee78910a3 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { execFileSync } = require('node:child_process') const { knex } = require('knex') const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { SnapshotArchiveCleanupPendingError } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveOwner.js') const executable = require('./snapshotArchiveDocker.cjs') const container = process.env.TS_STACK_SNAPSHOT_CONTAINER const expectedId = process.env.TS_STACK_SNAPSHOT_CONTAINER_ID @@ -132,8 +133,8 @@ async function captureFixture() { }) await writer.knex('users').where({ userId: user.userId }).update({ activeStorage: 'historical selection' }) let changedDuringCapture = false - KnexSnapshotArchiveStore.prototype.append = async function (owner, page) { - await originalAppend.call(this, owner, page) + KnexSnapshotArchiveStore.prototype.append = async function (owner, page, authorize) { + await originalAppend.call(this, owner, page, authorize) if (page.sequence === 0) { await writer .knex('tx_labels') @@ -149,7 +150,7 @@ async function captureFixture() { assert.equal(manifest.pages, 14) assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') assert.equal(manifest.binding.user.activeStorage, 'historical selection') - assert.equal(manifest.binding.sourceSchema, '2026-09-30-003 add snapshot archive requests') + assert.equal(manifest.binding.sourceSchema, '2026-10-01-001 add snapshot archive source owners') const store = new KnexSnapshotArchiveStore(writer.knex) const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) @@ -167,6 +168,7 @@ async function captureFixture() { await store.close(identity, manifest.archiveId) const requestLifecycle = await requestFixture(writer, reader) const remoteReader = await readerFixture(writer, reader) + const ownerDrain = await ownerDrainFixture(writer, reader) await writer.insertCommission({ created_at: date, updated_at: date, @@ -190,7 +192,8 @@ async function captureFixture() { packedBinary: true, crossProfileClosureRejected: true, requestLifecycle, - remoteReader + remoteReader, + ownerDrain } } finally { KnexSnapshotArchiveStore.prototype.append = originalAppend @@ -198,6 +201,91 @@ async function captureFixture() { await writer.destroy() } } +function boundary() { + let resolve + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +async function ownerDrainFixture(writer, reader) { + const controller = new KnexSnapshotArchiveService(writer) + const replacement = new KnexSnapshotArchiveService(reader) + const originalOpen = writer.openSnapshotArchiveSource.bind(writer) + const reading = boundary() + const allowRead = boundary() + const destroying = boundary() + const allowDestroy = boundary() + const fields = { + version: 1, + nonce: 'd'.repeat(64), + notAfter: (await snapshotArchiveDatabaseNow(writer.knex)) + 300000, + maxBytes: 1048576 + } + const input = { ...fields, requestId: snapshotArchiveRequestId(fields) } + writer.openSnapshotArchiveSource = async (...args) => { + const source = await originalOpen(...args) + assert.ok(source) + const pool = writer.snapshotSyncSource + assert.ok(pool) + const destroy = pool.destroy.bind(pool) + pool.destroy = async () => { + destroying.resolve() + await allowDestroy.promise + await destroy() + } + return { + ...source, + readPage: async (...pageArgs) => { + reading.resolve() + await allowRead.promise + return await source.readPage(...pageArgs) + } + } + } + const capture = controller.create(identity, input) + void capture.catch(() => undefined) + try { + await reading.promise + await assert.rejects(replacement.cancelRequest(identity, input), SnapshotArchiveCleanupPendingError) + assert.equal((await replacement.status(identity, input.requestId)).state, 'closed') + const requests = new KnexSnapshotArchiveRequestStore(reader.knex, true) + await requests.reap() + await new KnexSnapshotArchiveStore(reader.knex).reap() + assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).reservedBytes), fields.maxBytes) + assert.equal((await writer.knex('snapshot_archive_owners')).length, 1) + allowRead.resolve() + await destroying.promise + assert.equal((await writer.knex('snapshot_archive_pages')).length, 0) + assert.equal((await writer.knex('snapshot_archive_owners')).length, 1) + assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).reservedBytes), fields.maxBytes) + await writer.knex('tx_labels').where({ label: 'after-reader-pin' }).update({ label: 'during-owner-drain' }) + assert.equal((await writer.knex('tx_labels').where({ label: 'during-owner-drain' })).length, 1) + allowDestroy.resolve() + await assert.rejects(capture, /unavailable/) + assert.equal(writer.snapshotSyncSource, undefined) + assert.equal((await writer.knex('snapshot_archive_owners')).length, 0) + assert.equal((await writer.knex('snapshot_archives')).length, 0) + assert.equal((await writer.knex('snapshot_archive_pages')).length, 0) + assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).reservedBytes), 0) + await replacement.cancelRequest(identity, input) + return { + crossControllerCancellation: true, + nextAppendFenced: true, + reapingRetainsQuota: true, + foregroundWriteDuringPoolDrain: true, + exactPhysicalCleanupAcknowledgement: true + } + } finally { + allowRead.resolve() + allowDestroy.resolve() + await capture.catch(() => undefined) + writer.openSnapshotArchiveSource = originalOpen + await Promise.all([controller.close(), replacement.close()]) + } +} + async function readerFixture(writer, reader) { const owner = new KnexSnapshotArchiveRpc(writer) const replacement = new KnexSnapshotArchiveRpc(reader) @@ -229,7 +317,7 @@ async function readerFixture(writer, reader) { true ) try { - for (let iteration = 0; iteration < 5; iteration++) { + await runInSeries([0, 1, 2, 3, 4], async iteration => { receiver = owner const view = await openRemoteSnapshot(transport) assert.ok(view) @@ -244,32 +332,35 @@ async function readerFixture(writer, reader) { const labels = [] let cursor let done = false - while (!done) { - const page = await view.readPage('txLabels', cursor, { maxRows: 17 }) + function* pendingPages() { + while (!done) yield cursor + } + await runInSeries(pendingPages(), async next => { + const page = await view.readPage('txLabels', next, { maxRows: 17 }) assert.ok(page.rows.length <= 17) assert.equal(page.cursor.archivePosition.version, 1) assert.equal(page.cursor.archivePosition.archiveId.length, 64) labels.push(...page.rows) cursor = page.cursor done = page.done - } + }) assert.equal(labels.length, 140) assert.equal(labels[0].label, iteration === 0 ? 'after-service-pin' : 'after-reader-pin') assert.equal(new Set(labels.map(row => row.txLabelId)).size, 140) assert.ok(labels.every(row => row.userId === view.user.userId && row.created_at instanceof Date)) assert.ok(labels.every(row => typeof row.isDeleted === 'boolean')) - for (const table of snapshotArchiveTables) { + await runInSeries(snapshotArchiveTables, async table => { const page = await view.readPage(table, undefined, { maxRows: 1000 }) assert.equal(page.done, true) if (table === 'provenTxs') assert.deepEqual(page.rows[0].rawTx, new Uint8Array([1, 2, 255])) - } + }) } finally { await view.close() } assert.equal(Number((await writer.knex('snapshot_archive_capacity').first()).reservedBytes), 0) assert.equal((await writer.knex('snapshot_archive_requests')).length, retained) assert.equal((await writer.knex('snapshot_archive_pages')).length, 0) - } + }) assert.equal(captures, 5) assert.equal(admissions.length, 6) assert.deepEqual(admissions[0], admissions[1]) @@ -315,8 +406,8 @@ async function requestFixture(writer, reader) { claimedBeforePool = true return await originalOpen(...args) } - KnexSnapshotArchiveStore.prototype.append = async function (owner, page) { - await originalAppend.call(this, owner, page) + KnexSnapshotArchiveStore.prototype.append = async function (owner, page, authorize) { + await originalAppend.call(this, owner, page, authorize) if (page.sequence === 0) { const receipt = await replacement.create(identity, input) assert.equal(receipt.state, 'building') @@ -340,7 +431,7 @@ async function requestFixture(writer, reader) { sourceStorageIdentityKey: 'native-source', digest: ready.digest }) - assert.equal(verified.manifest.binding.sourceSchema, '2026-09-30-003 add snapshot archive requests') + assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-001 add snapshot archive source owners') const page = await replacement.read(identity, ready.archiveId, 8) const decoded = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[8])) assert.equal(decoded.rows[0].label, 'replacement') diff --git a/packages/wallet/wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts index 62ccf8ec8..8288caa0a 100644 --- a/packages/wallet/wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts +++ b/packages/wallet/wallet-toolbox/test/utils/remoteSnapshotReaderFixtures.ts @@ -56,7 +56,7 @@ export function remoteReaderFixture(rows: object[], table: WalletSnapshotTable = digest: directory.digest } } - const implementation: SnapshotArchiveRpcCall = async (method, params) => { + const response = (...[method, params]: Parameters): unknown => { const input = params[0] as { request?: SnapshotArchiveReaderRequest sequence?: number @@ -103,6 +103,13 @@ export function remoteReaderFixture(rows: object[], table: WalletSnapshotTable = throw new Error(`Unexpected fixture operation ${method}`) } } + const implementation: SnapshotArchiveRpcCall = (method, params) => { + try { + return Promise.resolve(response(method, params)) + } catch (error) { + return Promise.reject(error) + } + } const rpc = jest.fn(implementation) const transport = new SnapshotArchiveTransport( rpc, diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 473d42ded..5c65d8d1c 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -183,3 +183,12 @@ The implementation goal stays active until all required work is complete or an explicit external dependency prevents further progress. Prepare concrete release, deployment and funded-drill artifacts before requesting the separate operator authorization those actions require. PR #569 must remain open even when complete. + +The source-owner fence checkpoint adds an auxiliary exact-claim slot before +service capture and retains archive/request quota through physical cleanup. +Cross-controller cancellation stops the next atomic append; request and direct +archive cleanup both refuse to release outstanding owners. Ready publication +follows the same cleanup acknowledgement. This closes premature logical release +but deliberately does not reclaim unproved process loss: backend-bound recovery +and pending-cleanup polling remain required. The server reader capability stays +off and S3 remains open. From c1ec94c0d58e9a50873aa4c66f6067550a09f9c1 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 21:55:39 -0700 Subject: [PATCH 062/127] fix(wallet): await explicit remote snapshot cleanup acknowledgement --- docs/guides/wallet-sync-reliability.md | 30 ++- docs/reference/package-api-migrations.md | 84 ++++---- governance/mutation-testing/policy.json | 2 +- governance/mutation-testing/targets.mjs | 1 + governance/package-release-notes.json | 12 +- governance/test-quality/policy.json | 2 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 6 +- packages/wallet/wallet-toolbox/README.md | 5 +- .../archive/KnexSnapshotArchiveRpc.test.ts | 32 +++ .../archive/KnexSnapshotArchiveRpc.ts | 14 +- .../archive/RemoteSnapshotCleanup.test.ts | 201 ++++++++++++++++++ .../archive/RemoteSnapshotLease.test.ts | 2 +- .../snapshot/archive/RemoteSnapshotLease.ts | 4 +- .../RemoteSnapshotReader.property.test.ts | 48 +++++ .../archive/RemoteSnapshotReaderHttp.test.ts | 94 ++++++++ .../archive/SnapshotArchiveCleanup.ts | 84 ++++++++ .../snapshot/archive/SnapshotArchiveOwner.ts | 8 +- .../archive/SnapshotArchiveTransport.ts | 7 +- specs/wallet/sync-portability-program.md | 3 +- 19 files changed, 566 insertions(+), 73 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotCleanup.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveCleanup.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 2a8dd8b1c..c2d422575 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -629,7 +629,9 @@ The client now implements a source-only `getSnapshotSync()` adapter that negotiates the separate `snapshotArchiveReaderVersion: 1` setting when opened. The server does not yet advertise that setting. Controlled HTTP fixtures opt in to exercise the implementation; ordinary remote sync continues its existing path. -Old archive capability objects and all six legacy methods remain unchanged. +Old archive capability objects and legacy request/response shapes remain unchanged. +Legacy cancellation can report pending cleanup as an error; the explicit pending +receipt belongs only to the v2 reader. The new adapter refuses destination operations and does not imply portable export, staged restore or a remote destination implementation. @@ -688,8 +690,18 @@ the owner record remains. Each append checks the durable request fence in the same transaction as its page write. Direct archive close and expiry cleanup obey the same owner fence; a pending source does not prevent cleanup of other archives. -A pending close raises `SnapshotArchiveCleanupPendingError`; it does not return -successful cancellation. A failed local cleanup fences that controller's +A pending low-level close raises `SnapshotArchiveCleanupPendingError`; it does +not return successful cancellation. The v2 reader RPC carries an exact +`{ version: 1, outcome: 'cleanup-pending', requestId }` receipt. Only the existing +`true` acknowledgement proves completion. The higher reader validates that binding +and polls with one separate 30-second wall/monotonic cleanup allowance, at most 32 +attempts, and delays of 100, 200, 400, 800 and then at most 1,000 ms. Each immutable +operation retains its existing single native-fetch-loss retry. Neither source +lifetime nor request identity is renewed. Exhaustion rejects with cleanup still +pending. The deadline aborts outstanding I/O and waits for its settlement; it does +not abandon that I/O through a racing promise. Transports must separately bound +operations that ignore cancellation. Independent errors retain their identity, +and a late valid completion acknowledgement still proves cleanup. A failed local cleanup fences that controller's admission. Repeating an acknowledgement is idempotent and an old or incorrect claim cannot release a successor. The owner migration refuses removal while owners remain. Run migrations before admitting captures, keep all serving @@ -698,11 +710,19 @@ Older draft binaries do not enforce this new table; mixed-version capture is unsupported. Standard wallet tables and BRC-38/39 bytes are unchanged. This checkpoint deliberately retains ownership after an unproved process loss. -Backend-bound orphan recovery and bounded client polling for pending cleanup are -still required before reader advertisement. An elapsed lease is a fence, not +Backend-bound orphan recovery remains required before reader advertisement. An elapsed lease is a fence, not proof that an old SQL operation stopped. The eight logical slots do not establish a global physical-connection ceiling; per-provider physical admission remains occupied until its acquisition and cleanup settle. Actual deployment replica and driver limits require separate qualification. The generated two-connection lifecycle suite exercises cancellation, repeated status/reaping, incorrect and exact acknowledgements, and preserved reservations across at least 300 schedules. + +Real full/mobile HTTP tests route cancellation through an independently connected +controller while the original source pool is held in physical destruction. They +observe pending receipts, retained quota and successful foreground writes, then +confirm cleanup before the opening cancellation settles. Generated pending and +lost-acknowledgement schedules preserve one signal, bounded attempts, independent +failure identity and released timers/listeners. Same-server MySQL evidence covers +the source-side fence and delayed pool destruction; these tests do not establish +PXC, orphan recovery or a global physical-pool bound. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 241930d46..a6df93113 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | -| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | -| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | -| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | -| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | -| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | -| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | -| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | -| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | -| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/auth` | `0.1.5` | `0.1.5` | none | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to 2.9.0 and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. | +| `@bsv/authsocket` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. | +| `@bsv/btms` | `1.2.3` | `1.2.3` | none | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.1` | none | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. | +| `@bsv/chirp` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. | +| `@bsv/did` | `0.2.6` | `0.2.7` | patch | [API and usage](../packages/helpers/did.md) | None. ESM consumers are unaffected. | +| `@bsv/did-client` | `1.3.2` | `1.3.4` | patch | [API and usage](../packages/helpers/did-client.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.1` | none | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/gasp` | `1.3.7` | `1.3.7` | none | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. | +| `@bsv/lch` | `0.2.0` | `0.2.0` | none | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt SDK 2.9.0 to obtain the independently tested BRC-29 recipient-key correction. | +| `@bsv/overlay` | `2.6.2` | `2.6.2` | none | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/overlay-topics` | `1.9.1` | `1.9.2` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. | +| `@bsv/sdk` | `2.8.11` | `2.9.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. | +| `@bsv/simple` | `0.6.0` | `0.6.1` | patch | [API and usage](../packages/helpers/simple.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.6` | `0.3.6` | none | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.1` | none | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -523,8 +523,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Orphan recovery and pending-cleanup client polling remain incomplete; reader advertisement stays disabled. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Orphan recovery remains incomplete; reader advertisement stays disabled. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -537,8 +537,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. +- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. +- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -549,8 +549,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. +- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. +- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 7c2de869d..8dc1b9f9c 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -142,7 +142,7 @@ "manifest": "packages/wallet/wallet-toolbox/package.json", "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts", "risk": "critical", - "boundary": "Wallet immutable remote snapshot row decoding, fixed archive cursors, bounded leases and exact admission recovery", + "boundary": "Wallet immutable remote snapshot row decoding, fixed archive cursors, bounded leases, exact admission recovery and pending cleanup", "minimumScore": 90, "maximumNoCoverage": 0, "maximumInvalid": 0 diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 9f02721a4..dd5ad34a1 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -615,6 +615,7 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/archive/SnapshotArchiveReaderOffer.ts', 'src/storage/snapshot/archive/SnapshotArchiveAdmission.ts', 'src/storage/snapshot/archive/SnapshotArchiveTransportFailure.ts', + 'src/storage/snapshot/archive/SnapshotArchiveCleanup.ts', 'src/storage/snapshot/archive/RemoteSnapshotLease.ts', 'src/storage/snapshot/archive/RemoteSnapshotRows.ts', 'src/storage/snapshot/archive/RemoteSnapshotPageReader.ts', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index bba5cf344..0151254ee 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Orphan recovery and pending-cleanup client polling remain incomplete; reader advertisement stays disabled.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Orphan recovery remains incomplete; reader advertisement stays disabled. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged." + "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns.", + "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged." + "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns.", + "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." }, { "name": "create-bsv-app", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index cde5a75f3..9d2298af0 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -567,7 +567,7 @@ "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts", "manifest": "packages/wallet/wallet-toolbox/package.json", "risk": "critical", - "boundary": "Wallet immutable remote snapshot row decoding, fixed archive cursors, bounded leases and exact admission recovery", + "boundary": "Wallet immutable remote snapshot row decoding, fixed archive cursors, bounded leases, exact admission recovery and pending cleanup", "target": "Generated packed frame and page boundaries, immutable retry positions and per-page allocation accounting", "invariants": [ "Every generated row is returned exactly once in original source order across arbitrary frame and caller-page boundaries.", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index d80f17f70..01cf44a72 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -9,8 +9,10 @@ attention to changes that materially alter behavior or extend functionality. - Add an exact-claim source-owner fence and additive owner migration. Remote cancellation cannot release archive/request capacity or publish ready before the owning source and pool have closed. Append checks cancellation atomically; - direct archive cleanup follows the same fence. Orphan recovery and pending - cleanup polling remain incomplete; reader advertisement stays disabled. + direct archive cleanup follows the same fence. The reader validates request-bound + pending-cleanup receipts and uses a separate fixed deadline, bounded backoff and + awaited I/O settlement. Orphan recovery remains incomplete; reader advertisement + stays disabled. - Add the unadvertised remote row-reader foundation: immutable server-issued offers, exact-request retry, fixed leases, verified packed rows and durable diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index ddab49e88..3dab3a111 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -72,8 +72,9 @@ The candidate's additive source-owner table now retains request/archive capacity until the exact controller acknowledges physical source cleanup. Cross-controller cancellation fences the next page; pending cleanup reports an error and keeps its reservation. Do not mix older candidate binaries or remove the owner schema while -captures remain. Backend-bound orphan recovery and client pending-cleanup polling -are still incomplete, and the server reader capability remains unadvertised. +captures remain. The reader now waits for an exact completion acknowledgement through bounded +pending-cleanup polling, preserving independent failures. Backend-bound orphan +recovery remains incomplete, and the server reader capability is unadvertised. The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts index 1d90020da..d4c73b19d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts @@ -2,9 +2,41 @@ import { KnexSnapshotArchiveRpc } from './KnexSnapshotArchiveRpc' import { snapshotArchiveCapabilities } from './SnapshotArchiveProtocol' import { StorageKnex } from '../../StorageKnex' import { gate, snapshotHttpFixture } from '../../../../test/utils/snapshotArchiveHttpFixtures' +import type { SnapshotArchiveReaderOffer } from './SnapshotArchiveReaderOffer' +import type { KnexSnapshotArchiveService } from './KnexSnapshotArchiveService' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' afterEach(() => jest.restoreAllMocks()) +test.each([Error, WERR_INVALID_OPERATION])( + 'a %p cancellation failure cannot impersonate a pending receipt', + async ErrorType => { + const fixture = await snapshotHttpFixture() + const rpc = new KnexSnapshotArchiveRpc(fixture.storage) + try { + const issued = (await rpc.dispatch( + 'getSnapshotArchiveReaderOffer', + [{ version: 1, identityKey: fixture.identityKey, options: { lifetimeMs: 300000, maxBytes: 32768 } }], + fixture.identityKey + )) as SnapshotArchiveReaderOffer + if (issued.outcome !== 'offered') throw new Error('Fixture reader was not offered') + const failure = new ErrorType('Snapshot archive source cleanup is pending') + const service = Reflect.get(rpc, 'service') as KnexSnapshotArchiveService + jest.spyOn(service, 'cancelReader').mockRejectedValue(failure) + await expect( + rpc.dispatch( + 'cancelSnapshotArchiveRequest', + [{ version: 1, identityKey: fixture.identityKey, request: issued.request }], + fixture.identityKey + ) + ).rejects.toBe(failure) + } finally { + await rpc.close() + await fixture.close() + } + } +) + test.each([ 'snapshot_archive_requests', 'snapshot_archive_owners', diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts index de82bbf36..9d861d2cf 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts @@ -1,4 +1,5 @@ import type { SnapshotArchiveReaderOffer } from './SnapshotArchiveReaderOffer' +import { SnapshotArchiveCleanupPendingError, type SnapshotArchiveCleanupPending } from './SnapshotArchiveCleanup' import { WERR_INVALID_OPERATION, WERR_UNAUTHORIZED } from '../../../sdk/WERR_errors' import type { StorageKnex } from '../../StorageKnex' import { KnexSnapshotArchiveService } from './KnexSnapshotArchiveService' @@ -79,8 +80,17 @@ export class KnexSnapshotArchiveRpc { case 'admitSnapshotArchive': return await this.service.admitReader(identityKey, input.request) case 'cancelSnapshotArchiveRequest': - await this.service.cancelReader(identityKey, input.request) - return true + try { + await this.service.cancelReader(identityKey, input.request) + return true + } catch (error) { + if (!(error instanceof SnapshotArchiveCleanupPendingError)) throw error + return { + version: 1, + outcome: 'cleanup-pending', + requestId: input.request.requestId + } satisfies SnapshotArchiveCleanupPending + } case 'getSnapshotArchiveOffer': return await this.sourceOffer() case 'startSnapshotArchive': diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotCleanup.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotCleanup.test.ts new file mode 100644 index 000000000..922e82cfc --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotCleanup.test.ts @@ -0,0 +1,201 @@ +import { + drainSnapshotArchiveRequest, + SnapshotArchiveCleanupPendingError, + snapshotArchiveCleanupLimits, + validateSnapshotArchiveCancellation +} from './SnapshotArchiveCleanup' +import { SnapshotArchiveTransportFailure } from './SnapshotArchiveTransportFailure' +import { getEventListeners } from 'node:events' + +const requestId = 'a'.repeat(64) +const pending = () => ({ version: 1, outcome: 'cleanup-pending', requestId }) +function gate() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +afterEach(() => { + jest.restoreAllMocks() + jest.useRealTimers() +}) + +test('only a true cancellation acknowledgement proves completed cleanup', () => { + expect(validateSnapshotArchiveCancellation(true, requestId)).toBeUndefined() + expect(() => validateSnapshotArchiveCancellation(pending(), requestId)).toThrow(SnapshotArchiveCleanupPendingError) +}) + +test.each([ + undefined, + null, + false, + 1, + 'true', + [], + {}, + { ...pending(), version: 2 }, + { ...pending(), outcome: 'closed' }, + { ...pending(), requestId: 'b'.repeat(64) }, + { version: 1, outcome: 'cleanup-pending', other: requestId }, + { ...pending(), extra: true }, + { ...pending(), [Symbol('extra')]: true }, + Object.create(pending()), + Object.defineProperty({ ...pending() }, 'version', { value: 1, enumerable: false }) +])('malformed or foreign cancellation receipt %p cannot acknowledge cleanup', value => { + expect(() => validateSnapshotArchiveCancellation(value, requestId)).toThrow( + new TypeError('Invalid snapshot archive cancellation receipt') + ) +}) + +test('cancellation receipt validation never invokes an accessor', () => { + const getter = jest.fn(() => requestId) + const value = Object.defineProperty({ version: 1, outcome: 'cleanup-pending' }, 'requestId', { + get: getter, + enumerable: true + }) + expect(() => validateSnapshotArchiveCancellation(value, requestId)).toThrow(TypeError) + expect(getter).not.toHaveBeenCalled() +}) + +test('pending cleanup uses bounded exponential delay and one signal until a real acknowledgement', async () => { + jest.useFakeTimers({ now: 1000000 }) + const calls: number[] = [] + const signals: AbortSignal[] = [] + const cancel = jest.fn(async (signal: AbortSignal) => { + expect(getEventListeners(signal, 'abort')).toHaveLength(0) + calls.push(Date.now()) + signals.push(signal) + if (calls.length < 6) throw new SnapshotArchiveCleanupPendingError() + }) + let completed = false + const closing = drainSnapshotArchiveRequest(cancel).then(() => { + completed = true + }) + await jest.advanceTimersByTimeAsync(2499) + expect(completed).toBe(false) + expect(calls).toEqual([1000000, 1000100, 1000300, 1000700, 1001500]) + await jest.advanceTimersByTimeAsync(1) + await closing + expect(calls).toEqual([1000000, 1000100, 1000300, 1000700, 1001500, 1002500]) + expect(new Set(signals).size).toBe(1) + expect(signals[0].aborted).toBe(false) + expect(getEventListeners(signals[0], 'abort')).toHaveLength(0) + expect(jest.getTimerCount()).toBe(0) +}) + +test('repeated pending receipts terminate at the fixed attempt bound without claiming completion', async () => { + jest.useFakeTimers() + const cancel = jest.fn(async () => { + throw new SnapshotArchiveCleanupPendingError() + }) + const closing = drainSnapshotArchiveRequest(cancel) + const outcome = expect(closing).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + await jest.advanceTimersByTimeAsync(30000) + await outcome + expect(snapshotArchiveCleanupLimits).toEqual({ lifetimeMs: 30000, attempts: 32, delayMs: 1000 }) + expect(Object.isFrozen(snapshotArchiveCleanupLimits)).toBe(true) + expect(cancel).toHaveBeenCalledTimes(32) + expect(jest.getTimerCount()).toBe(0) +}) + +test.each(['pending', 'complete', 'failure'] as const)( + 'cleanup deadline aborts I/O but awaits its %s settlement', + async settlement => { + jest.useFakeTimers() + const entered = gate() + const release = gate() + const failure = new Error('synthetic independent cleanup failure') + let signal: AbortSignal | undefined + let settled = false + const cancel = jest.fn(async (current: AbortSignal) => { + signal = current + entered.resolve() + await release.promise + if (settlement === 'pending') throw new SnapshotArchiveCleanupPendingError() + if (settlement === 'failure') throw failure + }) + const closing = drainSnapshotArchiveRequest(cancel) + const observed = closing.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + try { + await entered.promise + expect(signal!.aborted).toBe(false) + await jest.advanceTimersByTimeAsync(30000) + expect(signal!.aborted).toBe(true) + expect(settled).toBe(false) + release.resolve() + if (settlement === 'complete') await expect(closing).resolves.toBeUndefined() + else if (settlement === 'failure') await expect(closing).rejects.toBe(failure) + else await expect(closing).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + expect(cancel).toHaveBeenCalledTimes(1) + await observed + expect(jest.getTimerCount()).toBe(0) + } finally { + release.resolve() + await observed + } + } +) + +test.each(['wall', 'monotonic'] as const)('stalled timers cannot extend the %s cleanup deadline', async clock => { + jest.useFakeTimers({ now: 1000000 }) + const monotonic = jest.spyOn(performance, 'now').mockReturnValue(4000) + const cancel = jest.fn(async () => { + if (clock === 'wall') jest.setSystemTime(1030000) + else { + jest.setSystemTime(-1000000) + monotonic.mockReturnValue(34000) + } + throw new SnapshotArchiveCleanupPendingError() + }) + await expect(drainSnapshotArchiveRequest(cancel)).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + expect(cancel).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) +}) + +test('only native transport loss retries, with the identical cleanup signal', async () => { + jest.useFakeTimers() + const signals: AbortSignal[] = [] + const loss = new SnapshotArchiveTransportFailure('synthetic lost cancellation acknowledgement') + const cancel = jest.fn(async (signal: AbortSignal) => { + signals.push(signal) + throw loss + }) + await expect(drainSnapshotArchiveRequest(cancel)).rejects.toBe(loss) + expect(cancel).toHaveBeenCalledTimes(2) + expect(signals[0]).toBe(signals[1]) + expect(jest.getTimerCount()).toBe(0) +}) + +test('a native failure at the deadline cannot start its retry', async () => { + jest.useFakeTimers({ now: 1000000 }) + const cancel = jest.fn(async () => { + jest.setSystemTime(1030000) + throw new SnapshotArchiveTransportFailure('synthetic deadline loss') + }) + await expect(drainSnapshotArchiveRequest(cancel)).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + expect(cancel).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) +}) + +test('timeout during pending backoff clears the delay and never starts another request', async () => { + jest.useFakeTimers() + const cancel = jest.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 29950)) + throw new SnapshotArchiveCleanupPendingError() + }) + const closing = drainSnapshotArchiveRequest(cancel) + const rejected = expect(closing).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + await jest.advanceTimersByTimeAsync(30000) + await rejected + expect(cancel).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts index 587a0d9f1..55d3b3e2d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts @@ -85,7 +85,7 @@ test('close fences synchronously, drains the client operation and cancels the ex expect(rpc).toHaveBeenCalledWith( 'cancelSnapshotArchiveRequest', [expect.objectContaining({ request: expected })], - undefined + expect.any(AbortSignal) ) expect(rpc).toHaveBeenCalledTimes(1) } finally { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts index e47c73499..189827144 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.ts @@ -5,6 +5,7 @@ import { isSnapshotArchiveTransportFailure, retrySnapshotArchiveOperation } from import type { WalletReadSnapshotOptions } from '../WalletReadSnapshot' import { parseSnapshotArchiveReaderRequest, type SnapshotArchiveReaderRequest } from './SnapshotArchiveReaderRequest' import type { SnapshotArchiveTransport } from './SnapshotArchiveTransport' +import { drainSnapshotArchiveRequest } from './SnapshotArchiveCleanup' function observedCompletion() { let resolve!: () => void @@ -165,7 +166,8 @@ export class RemoteSnapshotLease { this.closing = Promise.resolve().then(async () => { await pending const request = this.request - if (request !== undefined) await retrySnapshotArchiveOperation(async () => this.transport.cancelRequest(request)) + if (request !== undefined) + await drainSnapshotArchiveRequest(signal => this.transport.cancelRequest(request, signal)) }) void this.closing.then(this.resolveClosed, this.rejectClosed) this.controller.abort() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts index 729ffd02f..195c4944c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts @@ -2,6 +2,9 @@ import fc from 'fast-check' import { openRemoteSnapshot } from './openRemoteSnapshot' import { label, remoteReaderFixture } from '../../../../test/utils/remoteSnapshotReaderFixtures' import type { WalletSnapshotCursor } from '../WalletReadSnapshot' +import { drainSnapshotArchiveRequest, SnapshotArchiveCleanupPendingError } from './SnapshotArchiveCleanup' +import { SnapshotArchiveTransportFailure } from './SnapshotArchiveTransportFailure' +import { getEventListeners } from 'node:events' const MIN_PROPERTY_RUNS = 300 const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) @@ -13,6 +16,51 @@ fc.configureGlobal({ ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) }) +test('generated pending-cleanup and lost-ack schedules preserve one signal, failure identity and bounded polling', async () => { + jest.useFakeTimers() + try { + await fc.assert( + fc.asyncProperty(fc.array(fc.boolean(), { maxLength: 8 }), fc.boolean(), async (lost, fails) => { + let position = 0 + let retry = false + let calls = 0 + const signals = new Set() + const failure = new Error('synthetic independent cleanup failure') + const closing = drainSnapshotArchiveRequest(async signal => { + calls++ + signals.add(signal) + expect(signal.aborted).toBe(false) + expect(getEventListeners(signal, 'abort')).toHaveLength(0) + if (position < lost.length) { + if (lost[position] && !retry) { + retry = true + throw new SnapshotArchiveTransportFailure('synthetic lost acknowledgement') + } + position++ + retry = false + throw new SnapshotArchiveCleanupPendingError() + } + if (fails) throw failure + }) + const outcome = closing.then( + () => undefined, + error => error + ) + await jest.advanceTimersByTimeAsync(10000) + if (fails) expect(await outcome).toBe(failure) + else expect(await outcome).toBeUndefined() + expect(calls).toBe(1 + lost.length + lost.filter(Boolean).length) + expect(position).toBe(lost.length) + expect(signals.size).toBe(1) + expect(jest.getTimerCount()).toBe(0) + for (const signal of signals) expect(getEventListeners(signal, 'abort')).toHaveLength(0) + }) + ) + } finally { + jest.useRealTimers() + } +}) + test('generated frame/page boundaries preserve exact rows, final keys, allocation charges and immutable retries', async () => { await fc.assert( fc.asyncProperty( diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts index 58b65001f..15f688836 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts @@ -12,6 +12,7 @@ import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' import { WalletStorageManager } from '../../WalletStorageManager' +import { KnexSnapshotArchiveRpc } from './KnexSnapshotArchiveRpc' afterEach(() => jest.restoreAllMocks()) @@ -36,6 +37,99 @@ function rpcBody(init: RequestInit | undefined): { method: string; params: unkno return typeof body.method === 'string' ? body : undefined } +test.each([StorageClient, StorageMobile])( + 'authenticated %p cancellation waits for a second controller to confirm the original source cleanup', + async Client => { + const fixture = await snapshotHttpFixture() + const peer = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ ...fixture.storage.knex.client.config, pool: { min: 1, max: 1 } }) + }) + const replacement = new KnexSnapshotArchiveRpc(peer) + const reading = gate() + const allowRead = gate() + const destroying = gate() + const allowDestroy = gate() + const pendingCancellation = gate() + const outcomes: unknown[] = [] + let opening: Promise | undefined + try { + await peer.makeAvailable() + const { server, url } = await serveReader(fixture) + const originalRpc = Reflect.get(server, 'snapshotArchives') as KnexSnapshotArchiveRpc + const dispatch = originalRpc.dispatch.bind(originalRpc) + // Both clients use real authentication/framing through this HTTP edge. + // Its cancellation handler is routed to an independent SQL/controller instance. + jest.spyOn(originalRpc, 'dispatch').mockImplementation(async (method, params, identityKey) => { + if (method !== 'cancelSnapshotArchiveRequest') return await dispatch(method, params, identityKey) + const result = await replacement.dispatch(method, params, identityKey) + outcomes.push(result) + if (result !== true) pendingCancellation.resolve() + return result + }) + const originalOpen = fixture.storage.openSnapshotArchiveSource.bind(fixture.storage) + const open = jest.spyOn(fixture.storage, 'openSnapshotArchiveSource').mockImplementation(async (...args) => { + const source = (await originalOpen(...args))! + const owned = Reflect.get(fixture.storage, 'snapshotSyncSource') as StorageKnex + const destroy = owned.destroy.bind(owned) + jest.spyOn(owned, 'destroy').mockImplementation(async () => { + destroying.resolve() + await allowDestroy.promise + await destroy() + }) + const read = source.readPage + source.readPage = async (table, cursor, limits) => { + reading.resolve() + await allowRead.promise + return await read(table, cursor, limits) + } + return source + }) + const foreignOpen = jest.spyOn(peer, 'openSnapshotArchiveSource') + const controller = new AbortController() + const client = new Client(fixture.wallet, url, { serverIdentityKey: fixture.serverIdentityKey }) + opening = client.getSnapshotSync()!.openSource(fixture.identityKey, { signal: controller.signal }) + let settled = false + void opening.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await reading.promise + const request = await fixture.storage.knex('snapshot_archive_requests').first() + controller.abort() + await pendingCancellation.promise + expect(outcomes[0]).toEqual({ version: 1, outcome: 'cleanup-pending', requestId: request.requestId }) + expect(settled).toBe(false) + expect((await peer.knex('snapshot_archive_capacity').first()).archives).toBe(1) + allowRead.resolve() + await destroying.promise + expect(settled).toBe(false) + expect(await peer.knex('snapshot_archive_owners')).toHaveLength(1) + expect(await peer.knex('snapshot_archive_pages')).toHaveLength(0) + await peer.knex('tx_labels').where({ txLabelId: 1 }).update({ label: 'foreground while HTTP cleanup waits' }) + allowDestroy.resolve() + await expect(opening).rejects.toBeInstanceOf(SnapshotCancelledError) + expect(outcomes.at(-1)).toBe(true) + expect(open).toHaveBeenCalledTimes(1) + expect(foreignOpen).not.toHaveBeenCalled() + expect(await peer.knex('snapshot_archive_owners')).toHaveLength(0) + expect(await peer.knex('snapshot_archive_requests')).toHaveLength(0) + expect(await peer.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) + } finally { + allowRead.resolve() + allowDestroy.resolve() + await opening?.catch(() => undefined) + await replacement.close() + await peer.destroy() + await fixture.close() + } + } +) + test.each([StorageClient, StorageMobile])( 'authenticated %p reader negotiates its first open and keeps the original archive through replacement', async Client => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveCleanup.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveCleanup.ts new file mode 100644 index 000000000..0a0ec22fc --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveCleanup.ts @@ -0,0 +1,84 @@ +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { retrySnapshotArchiveOperation } from './SnapshotArchiveTransportFailure' + +/** Fencing has succeeded, but the source has not acknowledged cleanup. */ +export class SnapshotArchiveCleanupPendingError extends WERR_INVALID_OPERATION { + constructor() { + super('Snapshot archive source cleanup is pending') + } +} + +export interface SnapshotArchiveCleanupPending { + version: 1 + outcome: 'cleanup-pending' + requestId: string +} + +/** The existing true acknowledgement remains the only successful completion. */ +export function validateSnapshotArchiveCancellation(input: unknown, requestId: string): void { + if (input === true) return + if (input === null || typeof input !== 'object' || Array.isArray(input) || Reflect.ownKeys(input).length !== 3) + throw new TypeError('Invalid snapshot archive cancellation receipt') + const expected = { version: 1, outcome: 'cleanup-pending', requestId } + for (const [key, value] of Object.entries(expected)) { + const field = Object.getOwnPropertyDescriptor(input, key) + if (field === undefined || !('value' in field) || !field.enumerable || field.value !== value) + throw new TypeError('Invalid snapshot archive cancellation receipt') + } + throw new SnapshotArchiveCleanupPendingError() +} + +/** Separate fixed cleanup allowance; neither the source lease nor request identity is renewed. */ +export const snapshotArchiveCleanupLimits = Object.freeze({ lifetimeMs: 30000, attempts: 32, delayMs: 1000 }) + +function wait(milliseconds: number, signal: AbortSignal): Promise { + return new Promise(resolve => { + const finish = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', finish) + resolve() + } + const timer = setTimeout(finish, milliseconds) + signal.addEventListener('abort', finish, { once: true }) + // The private signal was checked immediately before this synchronous setup. + }) +} + +/** Bound polling and abort outstanding I/O, but await its settlement before releasing the caller. */ +export async function drainSnapshotArchiveRequest(cancel: (signal: AbortSignal) => Promise): Promise { + const startedAt = performance.now() + const expiresAt = Date.now() + snapshotArchiveCleanupLimits.lifetimeMs + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), snapshotArchiveCleanupLimits.lifetimeMs) + const remaining = (): number => + Math.min(expiresAt - Date.now(), snapshotArchiveCleanupLimits.lifetimeMs - (performance.now() - startedAt)) + const assertTime = (): void => { + if (controller.signal.aborted || remaining() <= 0) throw new SnapshotArchiveCleanupPendingError() + } + let complete = false + let delay = 100 + function* attempts() { + for (let attempt = 0; attempt < snapshotArchiveCleanupLimits.attempts && !complete; attempt++) yield attempt + } + try { + await runInSeries(attempts(), async () => { + assertTime() + try { + await retrySnapshotArchiveOperation(async () => { + assertTime() + await cancel(controller.signal) + }) + complete = true + } catch (error) { + if (!(error instanceof SnapshotArchiveCleanupPendingError)) throw error + assertTime() + await wait(Math.min(delay, remaining()), controller.signal) + delay = Math.min(delay * 2, snapshotArchiveCleanupLimits.delayMs) + } + }) + if (!complete) throw new SnapshotArchiveCleanupPendingError() + } finally { + clearTimeout(timer) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts index 76f86941b..33c1dcf00 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts @@ -1,17 +1,11 @@ import type { Knex } from 'knex' -import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' import { SnapshotArchiveAdmissionLimitError } from './SnapshotArchiveAdmission' import { snapshotArchiveLimits } from './SnapshotArchive' import type { SnapshotArchiveRequestOwner } from './SnapshotArchiveRequest' const table = 'snapshot_archive_owners' -/** The durable request is fenced, but its source has not proved cleanup yet. */ -export class SnapshotArchiveCleanupPendingError extends WERR_INVALID_OPERATION { - constructor() { - super('Snapshot archive source cleanup is pending') - } -} +export { SnapshotArchiveCleanupPendingError } from './SnapshotArchiveCleanup' /** Call only inside the shared capacity-locked claim transaction. */ export async function reserveSnapshotArchiveOwner(k: Knex, owner: SnapshotArchiveRequestOwner): Promise { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts index 47792d06b..a28eb0922 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.ts @@ -6,6 +6,7 @@ import { } from './SnapshotArchiveReaderOffer' import { parseSnapshotArchiveReaderRequest, type SnapshotArchiveReaderRequest } from './SnapshotArchiveReaderRequest' import { validateSnapshotArchiveAdmission, type SnapshotArchiveAdmission } from './SnapshotArchiveAdmission' +import { validateSnapshotArchiveCancellation } from './SnapshotArchiveCleanup' import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage, @@ -112,8 +113,10 @@ export class SnapshotArchiveTransport { async cancelRequest(input: SnapshotArchiveReaderRequest, signal?: AbortSignal): Promise { this.requireReader() const request = parseSnapshotArchiveReaderRequest(input) - if ((await this.call('cancelSnapshotArchiveRequest', { request }, signal)) !== true) - throw new TypeError('Invalid snapshot archive cancellation receipt') + validateSnapshotArchiveCancellation( + await this.call('cancelSnapshotArchiveRequest', { request }, signal), + request.requestId + ) } async directory( diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 5c65d8d1c..2a1dd83be 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -190,5 +190,6 @@ Cross-controller cancellation stops the next atomic append; request and direct archive cleanup both refuse to release outstanding owners. Ready publication follows the same cleanup acknowledgement. This closes premature logical release but deliberately does not reclaim unproved process loss: backend-bound recovery -and pending-cleanup polling remain required. The server reader capability stays +remains required. The client now validates exact pending-cleanup receipts and +uses fixed, bounded cancellation polling while awaiting I/O settlement. The server reader capability stays off and S3 remains open. From dd5637b34f3b3922458145bc08d3ff09fd48f291 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 22:59:47 -0700 Subject: [PATCH 063/127] docs: repair duplicate metadata after main reconciliation --- docs/packages/overlays/overlay-discovery-services.md | 7 ++----- docs/packages/overlays/overlay-express.md | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/docs/packages/overlays/overlay-discovery-services.md b/docs/packages/overlays/overlay-discovery-services.md index 2424016f3..780a48034 100644 --- a/docs/packages/overlays/overlay-discovery-services.md +++ b/docs/packages/overlays/overlay-discovery-services.md @@ -5,11 +5,8 @@ kind: package domain: overlays npm: '@bsv/overlay-discovery-services' version: '2.2.7' -last_updated: '2026-09-28' -last_verified: '2026-09-28' - -last_updated: '2026-09-26' -last_verified: '2026-09-26' +last_updated: '2026-10-01' +last_verified: '2026-10-01' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services' status: stable diff --git a/docs/packages/overlays/overlay-express.md b/docs/packages/overlays/overlay-express.md index 953fede6e..7e5511f5a 100644 --- a/docs/packages/overlays/overlay-express.md +++ b/docs/packages/overlays/overlay-express.md @@ -5,11 +5,8 @@ kind: package domain: overlays npm: '@bsv/overlay-express' version: '2.7.4' -last_updated: '2026-09-28' -last_verified: '2026-09-28' - -last_updated: '2026-09-26' -last_verified: '2026-09-26' +last_updated: '2026-10-01' +last_verified: '2026-10-01' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express' status: stable From 6df7c5f9bbe61a6dfb95137587dd38749ca54b7a Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 23:04:39 -0700 Subject: [PATCH 064/127] feat(wallet): recover snapshot owners behind physical backend guards --- docs/guides/wallet-sync-reliability.md | 63 ++- docs/reference/package-api-migrations.md | 74 +-- governance/mutation-testing/policy.json | 2 +- governance/mutation-testing/targets.mjs | 14 +- governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 7 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 6 +- packages/wallet/wallet-toolbox/README.md | 8 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 76 ++- .../src/storage/schema/KnexMigrations.ts | 12 + .../schema/snapshotArchiveGuardMigration.ts | 34 ++ .../ConcurrentSnapshotArchiveSource.test.ts | 2 +- .../KnexSnapshotArchiveCapture.test.ts | 8 +- .../KnexSnapshotArchiveRequestStore.ts | 5 +- .../archive/KnexSnapshotArchiveRpc.test.ts | 6 +- .../archive/KnexSnapshotArchiveRpc.ts | 1 + .../KnexSnapshotArchiveService.test.ts | 49 +- .../archive/KnexSnapshotArchiveService.ts | 19 +- .../archive/KnexSnapshotArchiveSource.ts | 6 +- .../archive/KnexSnapshotArchiveStore.test.ts | 2 +- .../archive/RemoteSnapshotReaderHttp.test.ts | 6 +- .../archive/SnapshotArchiveGuard.test.ts | 487 ++++++++++++++++++ .../snapshot/archive/SnapshotArchiveGuard.ts | 68 +++ .../SnapshotArchiveGuardBackend.test.ts | 227 ++++++++ .../archive/SnapshotArchiveGuardBackend.ts | 254 +++++++++ .../archive/SnapshotArchiveGuardRegistry.ts | 130 +++++ .../archive/SnapshotArchiveHttp.test.ts | 6 +- .../snapshot/archive/SnapshotArchiveOwner.ts | 8 +- .../SnapshotArchiveService.property.test.ts | 118 +++++ .../test/storage/snapshotArchiveCrash.cjs | 6 +- .../storage/snapshotArchiveGuardChild.cjs | 22 + .../storage/snapshotArchiveGuardCrash.cjs | 113 ++++ .../test/storage/snapshotArchiveMysql.cjs | 19 +- specs/wallet/sync-portability-program.md | 14 + 34 files changed, 1762 insertions(+), 114 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveGuardMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotArchiveGuardChild.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotArchiveGuardCrash.cjs diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index c2d422575..44d01a4fa 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -709,9 +709,9 @@ binaries on the same candidate, and stop and drain captures before any downgrade Older draft binaries do not enforce this new table; mixed-version capture is unsupported. Standard wallet tables and BRC-38/39 bytes are unchanged. -This checkpoint deliberately retains ownership after an unproved process loss. -Backend-bound orphan recovery remains required before reader advertisement. An elapsed lease is a fence, not -proof that an old SQL operation stopped. The eight logical slots do not establish +An elapsed lease is a fence, not proof that an old SQL operation stopped. The +backend guard implementation below permits recovery only after a separate proof +of source closure. The eight logical slots do not establish a global physical-connection ceiling; per-provider physical admission remains occupied until its acquisition and cleanup settle. Actual deployment replica and driver limits require separate qualification. The generated two-connection @@ -724,5 +724,58 @@ observe pending receipts, retained quota and successful foreground writes, then confirm cleanup before the opening cancellation settles. Generated pending and lost-acknowledgement schedules preserve one signal, bounded attempts, independent failure identity and released timers/listeners. Same-server MySQL evidence covers -the source-side fence and delayed pool destruction; these tests do not establish -PXC, orphan recovery or a global physical-pool bound. +the source-side fence and delayed pool destruction. These tests do not establish +PXC or a global physical-pool bound. + +## Backend-bound owner recovery (unadvertised implementation) + +Apply `2026-10-01-002 add snapshot archive source guards` through the normal +SQL migration entry point. It adds a guard version to owners and eight persistent +slot bindings. Existing owners default to version zero and are never inferred to +have a guard; they retain their reservation until explicit source cleanup. +New service captures bind their exact claim before opening a view and recheck +ownership, deadline and binding under the backend guard before reading wallet +data. Slot bindings survive release and reuse, preventing a delayed claimant +from choosing a new independent guard. + +For file-backed `better-sqlite3` WAL, each slot uses one private guard database +beside the canonical wallet database, named `.snapshot-owner-.sqlite`. +The binding records both files' device/inode identity and a persistent random +guard marker. The source holds a guard write lock on the same physical connection +as its wallet read view; foreground WAL writers remain independent. The private +pool closes the still-open transaction before acknowledging cleanup. An ordinary +commit or rollback would release the guard too early. A failed native close keeps +the guard and ownership pending rather than claiming drainage. + +MySQL binds the actual server UUID, database and fixed slot to a named advisory +lock on the source connection. Its next transaction is read-only repeatable-read. +Physical cleanup waits for the native socket close event, not just mysql2's +earlier quit callback or the stream's `destroyed` flag. A different actual backend +refuses the binding. This is a same-server contract; load-balanced/PXC failover +requires separate qualification and cannot infer safety from a configured URL. + +Admission and cancellation run one bounded recovery flight per provider, visiting +at most eight expired or terminal owners in sequence. A matching proof connection +must acquire the same guard, fence the exact claim under the shared capacity lock, +close physically and then acknowledge that owner. An occupied guard leaves cleanup +pending. A still-unbound claim can be fenced under the capacity lock before a late +binder enters. Independent backend and cleanup errors remain observable. Provider +and service shutdown await an already-started recovery flight. + +Run migrations before capture, keep candidate binaries uniform, and drain all +captures before downgrade. Guard files are persistent coordination state: do not +unlink, replace or recreate a bound file to clear a reservation. Missing files, +changed identities and changed markers refuse recovery. This implementation +qualifies stable local filesystems only; database relocation/restoration and +shared/distributed filesystems require an explicit binding-transition procedure +and separate evidence. Standard wallet tables and BRC-38/39 bytes are unchanged. + +Synthetic tests cover eight simultaneous views, immutable reads with foreground +writes, cancellation/expiry, delayed acquisition and native closure, stale-owner +acknowledgements, missing/changed guard identity and cleanup failure. At least 300 +generated guarded schedules exercise recovery and successor ownership. The +existing native crash and MySQL fixture commands also terminate an owned child +process, verify the reservation before loss and recover it afterward while +preserving foreground writes. These fixtures do not establish deployed limits, +distributed filesystems, PXC or physical mobile behavior. Reader advertisement +remains disabled pending complete lifecycle and program qualification. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 5d48cc176..32f61f9d1 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Orphan recovery remains incomplete; reader advertisement stays disabled. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index f6208bc79..a5fc2d4e8 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -122,7 +122,7 @@ "manifest": "packages/wallet/wallet-toolbox/package.json", "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts", "risk": "critical", - "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation, exact source ownership and cleanup lifecycle", + "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation, exact source ownership, backend guards and physical cleanup recovery", "minimumScore": 90, "maximumNoCoverage": 0, "maximumInvalid": 0 diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 966f8a775..defd5900c 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -102,6 +102,13 @@ function snapshotSyncMutationTargets(repositoryRoot) { 'override getSnapshotSync():', 'protected override supportsNoSendExpiryPersistence()' ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override async destroy(): Promise', + 'override async migrate(' + ), sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', @@ -487,7 +494,11 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/archive/SnapshotArchiveSql.ts', 'src/storage/schema/snapshotArchiveRequestMigration.ts', 'src/storage/snapshot/archive/SnapshotArchiveOwner.ts', - 'src/storage/schema/snapshotArchiveOwnerMigration.ts' + 'src/storage/schema/snapshotArchiveOwnerMigration.ts', + 'src/storage/schema/snapshotArchiveGuardMigration.ts', + 'src/storage/snapshot/archive/SnapshotArchiveGuard.ts', + 'src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts', + 'src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts' ], ...jestTarget( 'jest.config.cjs', @@ -496,6 +507,7 @@ export function buildMutationTargets(repositoryRoot) { '/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts', '/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts', '/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts', + '/src/storage/snapshot/archive/SnapshotArchiveGuard*.test.ts', '/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts' ], { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 70913f6ac..3fe1516c4 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Orphan recovery remains incomplete; reader advertisement stays disabled. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Unproved process loss retains ownership pending backend-bound recovery. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 1b7daacd5..72f23dc9a 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -596,14 +596,15 @@ "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts", "manifest": "packages/wallet/wallet-toolbox/package.json", "risk": "critical", - "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation, exact source ownership and cleanup lifecycle", - "target": "Generated SQL claim, retry, capture publication, cross-controller cancellation and exact-owner cleanup schedules", + "boundary": "Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation, exact source ownership, backend guards and physical cleanup recovery", + "target": "Generated SQL claim, retry, capture publication, cross-controller cancellation, delayed native close, guarded recovery and successor ownership schedules", "invariants": [ "A retry returns the original request and immutable ready archive without a second admission or writer token.", "Pending requests reserve the same shared capacity used by local archives before source acquisition.", "Archive assignment and ready receipt publication commit atomically.", "Remote cancellation, repeated reaping and incorrect acknowledgements retain pages and quota while the exact source owner remains.", - "Terminal requests retain bounded receipts and release their logical reservation exactly once after the source owner acknowledges physical cleanup." + "Terminal requests retain bounded receipts and release their logical reservation exactly once after the source owner acknowledges physical cleanup.", + "Backend guards retain quota through native close; recovery fences the exact terminal owner and old acknowledgements cannot release a successor." ] }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 01cf44a72..493a9eef5 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -11,8 +11,10 @@ attention to changes that materially alter behavior or extend functionality. the owning source and pool have closed. Append checks cancellation atomically; direct archive cleanup follows the same fence. The reader validates request-bound pending-cleanup receipts and uses a separate fixed deadline, bounded backoff and - awaited I/O settlement. Orphan recovery remains incomplete; reader advertisement - stays disabled. + awaited I/O settlement. Add fixed-slot SQLite WAL/MySQL backend guards and + exact-owner recovery only after physical connection closure. Preserve old + unguarded reservations and refuse changed backend identities. Reader + advertisement stays disabled pending complete qualification. - Add the unadvertised remote row-reader foundation: immutable server-issued offers, exact-request retry, fixed leases, verified packed rows and durable diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 3dab3a111..b0455275b 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -73,8 +73,12 @@ until the exact controller acknowledges physical source cleanup. Cross-controlle cancellation fences the next page; pending cleanup reports an error and keeps its reservation. Do not mix older candidate binaries or remove the owner schema while captures remain. The reader now waits for an exact completion acknowledgement through bounded -pending-cleanup polling, preserving independent failures. Backend-bound orphan -recovery remains incomplete, and the server reader capability is unadvertised. +pending-cleanup polling, preserving independent failures. The additive guard +migration now permits exact-owner recovery after physical source closure, using +stable local SQLite WAL guard files or a same-server MySQL connection lock. +Older unguarded owners remain reserved, and changed backend identities fail closed. +Keep the guard files and bindings intact and drain captures before downgrade. +The server reader capability remains unadvertised pending complete qualification. The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 8ecb0bce3..433dd09ce 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,4 +1,6 @@ import { SnapshotResourceLimitError } from './snapshot/SnapshotResourceLimitError' +import { readGuardedSnapshotArchive, recoverSnapshotArchiveGuards } from './snapshot/archive/SnapshotArchiveGuard' +import type { SnapshotArchiveRequestOwner } from './snapshot/archive/SnapshotArchiveRequest' import { SnapshotCancelledError } from './snapshot/SnapshotCancelledError' import type { SnapshotSyncStorage } from './snapshot/SnapshotSync' import { KnexSnapshotSyncDestination } from './snapshot/KnexSnapshotSyncDestination' @@ -136,6 +138,8 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide private snapshotSyncSource?: StorageKnex private snapshotSyncOpening?: Promise private snapshotSyncBusy = false + private snapshotArchiveRecovery?: Promise + private guardedSnapshotReadFailure?: { error: unknown } private retainedReadSnapshot?: RetainedReadSnapshotLifetime private retainedReadSnapshotsStopped = false readonly preparedBeefPolicy: PreparedBeefPolicy @@ -229,6 +233,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide /** One retained local transaction per provider; it occupies one pool connection until physical cleanup. */ override async openReadSnapshot(options: RetainedReadSnapshotOptions = {}): Promise { + return await this.openTrackedReadSnapshot(options, read => this.readSnapshot(read)) + } + + private async openTrackedReadSnapshot( + options: RetainedReadSnapshotOptions, + transaction: (read: (trx: TrxToken) => Promise) => Promise + ): Promise { if (this.retainedReadSnapshotsStopped) { throw new WERR_INVALID_OPERATION('Retained read snapshots are unavailable after provider destruction begins') } @@ -237,7 +248,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide throw new WERR_INVALID_OPERATION('This provider already has a retained read snapshot opening or active') } const lifetime = retainReadSnapshot( - read => this.readSnapshot(read), + transaction, async trx => { // Pin SQLite's deferred read view before opening resolves. MySQL also // establishes its repeatable-read snapshot on this first data read. @@ -257,9 +268,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide // Fence admission before any asynchronous cleanup can yield. A view whose // close releases the capacity slot must not permit reopening during destroy. this.retainedReadSnapshotsStopped = true - await this.snapshotSyncOpening?.catch(() => undefined) - await this.snapshotSyncSource?.destroy() - await this.retainedReadSnapshot?.close() + try { + await this.snapshotArchiveRecovery + } finally { + await this.snapshotSyncOpening?.catch(() => undefined) + await this.snapshotSyncSource?.destroy() + await this.retainedReadSnapshot?.close() + } } override supportsWalletReadSnapshot(): boolean { @@ -322,23 +337,66 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide async supportsSnapshotArchiveSource(): Promise { if (this.retainedReadSnapshotsStopped) return false const config = await this.concurrentSnapshotReaderConfig() - return !this.retainedReadSnapshotsStopped && config !== undefined + return ( + !this.retainedReadSnapshotsStopped && + config !== undefined && + (config.client === 'better-sqlite3' || String(config.client).includes('mysql')) + ) } /** Shares local sync's single owned reader slot and awaited physical cleanup. */ async openSnapshotArchiveSource( identityKey: string, - options: WalletReadSnapshotOptions = {} + options: WalletReadSnapshotOptions = {}, + owner?: SnapshotArchiveRequestOwner ): Promise { + const claim = owner === undefined ? undefined : { ...owner } try { - return await this.openConcurrentSyncSource(identityKey, options, openKnexSnapshotArchiveSource) + return await this.openConcurrentSyncSource(identityKey, options, async (reader, identity, settings) => { + if (claim === undefined) return await openKnexSnapshotArchiveSource(reader, identity, settings) + return await openKnexSnapshotArchiveSource(reader, identity, settings, () => + reader.openTrackedReadSnapshot(settings, async read => { + try { + await reader.makeAvailable() + await readGuardedSnapshotArchive(this.knex, reader.knex, claim, read) + } catch (error) { + if (!(error instanceof SnapshotArchiveSourceCleanupError)) reader.guardedSnapshotReadFailure = { error } + throw error + } + }) + ) + }) } catch (error) { + if (error instanceof SnapshotArchiveSourceCleanupError) throw error if (this.retainedReadSnapshotsStopped && this.snapshotSyncSource !== undefined) throw new SnapshotArchiveSourceCleanupError(error) throw error } } + /** One bounded recovery flight per provider, drained by provider destruction. */ + recoverSnapshotArchiveSources(): Promise { + if (this.retainedReadSnapshotsStopped) + return Promise.reject(new WERR_INVALID_OPERATION('Snapshot source recovery is unavailable after destruction')) + if (this.snapshotArchiveRecovery !== undefined) return this.snapshotArchiveRecovery + const recovery = Promise.resolve() + .then(async () => { + const config = await this.concurrentSnapshotReaderConfig() + if (config === undefined) return + await recoverSnapshotArchiveGuards(this.knex, config) + }) + .finally(() => { + if (this.snapshotArchiveRecovery === recovery) this.snapshotArchiveRecovery = undefined + }) + this.snapshotArchiveRecovery = recovery + return recovery + } + + /** Drain an already-started recovery without admitting another one during service shutdown. */ + awaitSnapshotArchiveRecovery(): Promise { + return this.snapshotArchiveRecovery ?? Promise.resolve() + } + private async openConcurrentSyncSource( identityKey: string, options: WalletReadSnapshotOptions, @@ -1927,6 +1985,10 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide override async destroy(): Promise { try { await this.stopRetainedReadSnapshots() + } catch (error) { + // The retained API preserves its read failure. That same failure does + // not mean the private pool's subsequent physical destruction failed. + if (this.guardedSnapshotReadFailure === undefined || this.guardedSnapshotReadFailure.error !== error) throw error } finally { await this.stopPreparedBeefTasks() this.knex.off('query', this.onQuery) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index c0e90767c..a222973b3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,8 @@ +import { + addSnapshotArchiveGuardTable, + removeSnapshotArchiveGuardTable, + SNAPSHOT_ARCHIVE_GUARD_MIGRATION +} from './snapshotArchiveGuardMigration' import { addSnapshotArchiveOwnerTable, removeSnapshotArchiveOwnerTable, @@ -28,6 +33,7 @@ import { } from '../methods/managedChangePolicy' export { SNAPSHOT_ARCHIVE_OWNER_MIGRATION } from './snapshotArchiveOwnerMigration' +export { SNAPSHOT_ARCHIVE_GUARD_MIGRATION } from './snapshotArchiveGuardMigration' export { SNAPSHOT_ARCHIVE_REQUEST_MIGRATION } from './snapshotArchiveRequestMigration' export { SNAPSHOT_ARCHIVE_MIGRATION } from './snapshotArchiveMigration' export { SNAPSHOT_SYNC_MIGRATION } from './snapshotSyncMigration' @@ -114,6 +120,12 @@ export class KnexMigrations implements MigrationSource { } } + migrations[SNAPSHOT_ARCHIVE_GUARD_MIGRATION] = { + config: { transaction: true }, + up: addSnapshotArchiveGuardTable, + down: removeSnapshotArchiveGuardTable + } + migrations[SNAPSHOT_ARCHIVE_OWNER_MIGRATION] = { config: { transaction: true }, up: addSnapshotArchiveOwnerTable, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveGuardMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveGuardMigration.ts new file mode 100644 index 000000000..4621465a0 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotArchiveGuardMigration.ts @@ -0,0 +1,34 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { snapshotArchiveLimits } from '../snapshot/archive/SnapshotArchive' + +export const SNAPSHOT_ARCHIVE_GUARD_MIGRATION = '2026-10-01-002 add snapshot archive source guards' + +/** Older candidate owners default to zero and can never be inferred to use a guard. */ +export async function addSnapshotArchiveGuardTable(knex: Knex): Promise { + if (!(await knex.schema.hasColumn('snapshot_archive_owners', 'guardVersion'))) { + await knex.schema.alterTable('snapshot_archive_owners', table => { + table.integer('guardVersion').notNullable().defaultTo(0) + }) + } + if (!(await knex.schema.hasTable('snapshot_archive_owner_slots'))) { + await knex.schema.createTable('snapshot_archive_owner_slots', table => { + table.integer('slot').primary() + table.text('bindingJson').nullable() + }) + } + await knex('snapshot_archive_owner_slots') + .insert(Array.from({ length: snapshotArchiveLimits.archives }, (_, slot) => ({ slot, bindingJson: null }))) + .onConflict('slot') + .ignore() +} + +export async function removeSnapshotArchiveGuardTable(knex: Knex): Promise { + if ((await knex('snapshot_archive_owners').first('slot')) !== undefined) { + throw new WERR_INVALID_OPERATION('Drain snapshot archive sources before removing their guards') + } + await knex.schema.dropTableIfExists('snapshot_archive_owner_slots') + if (await knex.schema.hasColumn('snapshot_archive_owners', 'guardVersion')) { + await knex.schema.alterTable('snapshot_archive_owners', table => table.dropColumn('guardVersion')) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts index 4de0a487b..48dfd6b5b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -60,7 +60,7 @@ test('a ready request waits for owned reader destruction while foreground storag const source = (await storage.openSnapshotArchiveSource(identity))! expect(source.user.identityKey).toBe(identity) expect(source.sourceStorage.storageIdentityKey).toBe('original-source') - expect(source.sourceSchema).toBe('2026-10-01-001 add snapshot archive source owners') + expect(source.sourceSchema).toBe('2026-10-01-002 add snapshot archive source guards') const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex expect(reader.knex).not.toBe(storage.knex) expect(reader.knex.client.config.pool).toMatchObject({ min: 0, max: 1 }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index 33d64ad26..adb3590de 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' -import { SNAPSHOT_ARCHIVE_OWNER_MIGRATION } from '../../schema/KnexMigrations' +import { SNAPSHOT_ARCHIVE_GUARD_MIGRATION } from '../../schema/KnexMigrations' import { decodeSyncTransfer } from '../../remoting/SyncTransfer' import * as Transfer from '../../remoting/SyncTransfer' import * as ArchiveSource from './KnexSnapshotArchiveSource' @@ -72,7 +72,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { onProgress: p => progress.push(p) }) - expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_ARCHIVE_OWNER_MIGRATION) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_ARCHIVE_GUARD_MIGRATION) expect(manifest.binding.sourceStorage.storageName).toBe('original source') expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) @@ -84,7 +84,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof sourceStorageIdentityKey: 'original-source', archiveId: manifest.archiveId, digest: manifest.digest, - sourceSchema: SNAPSHOT_ARCHIVE_OWNER_MIGRATION + sourceSchema: SNAPSHOT_ARCHIVE_GUARD_MIGRATION }) expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} @@ -153,7 +153,7 @@ test('source schema, primary history and closure stay pinned while an independen await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) - expect(source.sourceSchema).toBe(SNAPSHOT_ARCHIVE_OWNER_MIGRATION) + expect(source.sourceSchema).toBe(SNAPSHOT_ARCHIVE_GUARD_MIGRATION) expect(source.user.activeStorage).toBe(originalPrimary) await expect(source.validateClosure()).resolves.toBeUndefined() expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts index 35511254c..67fe8f721 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.ts @@ -71,7 +71,8 @@ function identifier(value: string): void { export class KnexSnapshotArchiveRequestStore { constructor( private readonly knex: Knex, - private readonly requireSourceDrain = false + private readonly requireSourceDrain = false, + private readonly guardedSources = false ) {} private async receipt(k: Knex, row: RequestRow): Promise { @@ -202,7 +203,7 @@ export class KnexSnapshotArchiveRequestStore { } if (reader) await trx(table).where({ identityKey, requestId: request.requestId }).update(row) else await trx(table).insert(row) - if (this.requireSourceDrain) await reserveSnapshotArchiveOwner(trx, owner) + if (this.requireSourceDrain) await reserveSnapshotArchiveOwner(trx, owner, this.guardedSources) await trx('snapshot_archive_capacity') .where({ id: 1 }) .update({ archives: capacity.archives + 1, reservedBytes: Number(capacity.reservedBytes) + request.maxBytes }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts index d4c73b19d..e336345dd 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts @@ -87,7 +87,11 @@ test('unsupported source and a close during capability probing never advertise o }) test('unsupported chain is declined and remains checked before an offer', async () => { - const storage = { chain: 'mock', knex: {} } as unknown as StorageKnex + const storage = { + chain: 'mock', + knex: {}, + awaitSnapshotArchiveRecovery: async () => undefined + } as unknown as StorageKnex const rpc = new KnexSnapshotArchiveRpc(storage) expect(await rpc.capabilities()).toBeUndefined() jest.spyOn(rpc, 'capabilities').mockResolvedValue(snapshotArchiveCapabilities) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts index 9d861d2cf..019127b52 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts @@ -32,6 +32,7 @@ export class KnexSnapshotArchiveRpc { for (const name of [ 'snapshot_archive_requests', 'snapshot_archive_owners', + 'snapshot_archive_owner_slots', 'snapshot_archives', 'snapshot_archive_pages', 'snapshot_archive_capacity' diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts index 4c5023159..b4eef3801 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts @@ -13,6 +13,7 @@ import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage } from './SnapshotArchiveDirectory' import * as ArchiveSql from './SnapshotArchiveSql' +import * as ArchiveGuard from './SnapshotArchiveGuard' const identity = '02' + '11'.repeat(32) const other = '03' + '22'.repeat(32) @@ -80,13 +81,13 @@ test('capture reserves before reader acquisition and a replacement server recove const { storage, controller, open } = await fixture() const input = request() const original = storage.openSnapshotArchiveSource.bind(storage) - const opening = jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options) => { + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options, owner) => { expect(await storage.knex('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) expect((await controller.status(identity, input.requestId)).state).toBe('building') expect(options?.signal).toBeInstanceOf(AbortSignal) expect(options?.lifetimeMs).toBeGreaterThan(0) expect(options?.lifetimeMs).toBeLessThanOrEqual(300000) - return await original(key, options) + return await original(key, options, owner) }) const pending = controller.create(identity, input) expect(controller.create(identity, { ...input })).toBe(pending) @@ -136,9 +137,9 @@ test.each(['cancel', 'shutdown'] as const)( const destroying = gate() const allowDestroy = gate() const original = storage.openSnapshotArchiveSource.bind(storage) - jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options) => { + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options, owner) => { // Acquire the actual view, but withhold it from the controller until cancellation. - const source = (await original(key, { ...options, signal: undefined }))! + const source = (await original(key, { ...options, signal: undefined }, owner))! const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex const destroy = reader.destroy.bind(reader) jest.spyOn(reader, 'destroy').mockImplementation(async () => { @@ -227,15 +228,17 @@ test('capture failure closes the reader, records a terminal failure and allows a const { storage, controller } = await fixture() const input = request() const original = storage.openSnapshotArchiveSource.bind(storage) - const opening = jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options) => { - const source = (await original(key, options))! - return { - ...source, - validateClosure: async () => { - throw new Error('fixture closure failure') + const opening = jest + .spyOn(storage, 'openSnapshotArchiveSource') + .mockImplementationOnce(async (key, options, owner) => { + const source = (await original(key, options, owner))! + return { + ...source, + validateClosure: async () => { + throw new Error('fixture closure failure') + } } - } - }) + }) await expect(controller.create(identity, input)).rejects.toThrow('fixture closure failure') expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() expect((await controller.create(identity, input)).state).toBe('failed') @@ -250,8 +253,8 @@ test('failed physical cleanup retains the reservation and fences the controller const original = storage.openSnapshotArchiveSource.bind(storage) const failure = new Error('physical close failed') let source: Awaited> - jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options) => { - source = (await original(key, options))! + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options, owner) => { + source = (await original(key, options, owner))! return { ...source, close: async () => { @@ -327,7 +330,9 @@ test('failed cleanup while opening retains admission even though no source was r const input = request() const failure = new Error('owned pool destruction failed') const destroy = StorageKnex.prototype.destroy - jest.spyOn(StorageKnex.prototype, 'openReadSnapshot').mockRejectedValueOnce(new Error('reader initialization failed')) + jest + .spyOn(ArchiveGuard, 'readGuardedSnapshotArchive') + .mockRejectedValueOnce(new Error('reader initialization failed')) jest.spyOn(StorageKnex.prototype, 'destroy').mockImplementation(async function (this: StorageKnex) { if (this !== storage) throw failure await destroy.call(this) @@ -358,10 +363,10 @@ test('start returns a durable receipt before capture completes and repeats only const entered = gate() const finish = gate() const original = storage.openSnapshotArchiveSource.bind(storage) - jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options) => { + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options, owner) => { entered.resolve() await finish.promise - return await original(key, options) + return await original(key, options, owner) }) const accepted = controller.start(identity, input) expect(controller.start(identity, { ...input })).toBe(accepted) @@ -616,14 +621,14 @@ test('cancellation through another controller retains quota until the capturing const destroying = gate() const allowDestroy = gate() const original = storage.openSnapshotArchiveSource.bind(storage) - jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options) => { - const source = (await original(key, options))! + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options, owner) => { + const source = (await original(key, options, owner))! const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex - const destroy = reader.destroy.bind(reader) - jest.spyOn(reader, 'destroy').mockImplementation(async () => { + const destroy = reader.knex.client.destroyRawConnection.bind(reader.knex.client) + jest.spyOn(reader.knex.client, 'destroyRawConnection').mockImplementation(async connection => { destroying.resolve() await allowDestroy.promise - await destroy() + await destroy(connection) }) return { ...source, diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts index c2d0d4ae5..110bcadfb 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.ts @@ -36,7 +36,7 @@ export class KnexSnapshotArchiveService { private cleanupFailure?: { error: unknown } constructor(private readonly storage: StorageKnex) { - this.requests = new KnexSnapshotArchiveRequestStore(storage.knex, true) + this.requests = new KnexSnapshotArchiveRequestStore(storage.knex, true, true) this.archives = new KnexSnapshotArchiveStore(storage.knex) } @@ -80,6 +80,7 @@ export class KnexSnapshotArchiveService { this.assertOpen() const options = validateSnapshotArchiveReaderOptions(input) if (this.active !== undefined) return undefined + await this.storage.recoverSnapshotArchiveSources() await this.requests.reap() this.assertOpen() await this.archives.reap() @@ -111,6 +112,7 @@ export class KnexSnapshotArchiveService { const claim = Promise.resolve().then(async () => { assertSnapshotArchiveCaptureActive(job.controller.signal) // Reap outside the claim transaction, before reserving or opening a pool. + await this.storage.recoverSnapshotArchiveSources() await this.requests.reap() await this.archives.reap() assertSnapshotArchiveCaptureActive(job.controller.signal) @@ -162,10 +164,11 @@ export class KnexSnapshotArchiveService { assertSnapshotArchiveCaptureActive(controller.signal) const remaining = request.notAfter - (await snapshotArchiveDatabaseNow(this.storage.knex)) if (remaining < 1) throw new SnapshotResourceLimitError('Snapshot archive request expired before capture') - source = await this.storage.openSnapshotArchiveSource(identityKey, { - signal: controller.signal, - lifetimeMs: Math.min(300000, remaining) - }) + source = await this.storage.openSnapshotArchiveSource( + identityKey, + { signal: controller.signal, lifetimeMs: Math.min(300000, remaining) }, + owner + ) if (source === undefined) throw new WERR_NOT_IMPLEMENTED('Snapshot archive capture requires SQLite WAL or MySQL') await captureSnapshotArchiveSource( source, @@ -221,6 +224,7 @@ export class KnexSnapshotArchiveService { if (job?.identityKey === identityKey && job.request.requestId === request.requestId) await this.stop(job) // A different replica can fence the request, but only proved source cleanup // permits release. Its pending outcome must not be acknowledged as complete. + await this.storage.recoverSnapshotArchiveSources() await this.requests.close(identityKey, request.requestId) } @@ -230,6 +234,7 @@ export class KnexSnapshotArchiveService { await this.requests.markReaderCancellation(identityKey, request) const job = this.active if (job?.identityKey === identityKey && job.request.requestId === request.requestId) await this.stop(job) + await this.storage.recoverSnapshotArchiveSources() await this.requests.close(identityKey, request.requestId) } @@ -237,6 +242,7 @@ export class KnexSnapshotArchiveService { this.assertOpen() const job = this.active if (job?.identityKey === identityKey && job.request.requestId === requestId) await this.stop(job) + await this.storage.recoverSnapshotArchiveSources() await this.requests.close(identityKey, requestId) } @@ -245,7 +251,8 @@ export class KnexSnapshotArchiveService { this.stopped = true if (this.closing === undefined) { const job = this.active - this.closing = job === undefined ? this.closedWithoutCapture() : this.stop(job) + const capture = job === undefined ? this.closedWithoutCapture() : this.stop(job) + this.closing = capture.finally(() => this.storage.awaitSnapshotArchiveRecovery()) } return this.closing } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index a1c04e8a8..254ec1582 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -5,6 +5,7 @@ import type { StorageKnex } from '../../StorageKnex' import { createKnexWalletSnapshotPageReader } from '../KnexWalletReadSnapshot' import type { WalletReadSnapshot, WalletReadSnapshotOptions } from '../WalletReadSnapshot' import { assertKnexSnapshotArchiveClosure } from './KnexSnapshotArchiveClosure' +import type { RetainedReadSnapshot } from '../RetainedReadSnapshot' /** Internal ownership failure; no caller may release its admission as cleaned up. */ export class SnapshotArchiveSourceCleanupError extends Error { @@ -41,12 +42,13 @@ export async function readSnapshotArchiveSourceSchema(storage: StorageKnex, k: K export async function openKnexSnapshotArchiveSource( storage: StorageKnex, identityKey: string, - options: WalletReadSnapshotOptions = {} + options: WalletReadSnapshotOptions = {}, + openView: () => Promise = () => storage.openReadSnapshot(options) ): Promise { if (typeof identityKey !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(identityKey)) { throw new WERR_INVALID_PARAMETER('identityKey', 'a compressed public identity key') } - const view = await storage.openReadSnapshot(options) + const view = await openView() try { const header = await view.read(async trx => { const sourceStorage = await storage.readSettings(trx) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index 62aaa76ac..da296e5fd 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -631,7 +631,7 @@ test('only acknowledged sequence positions are readable, even if an unacknowledg test('the auxiliary migration is registered after the durable sync schema', async () => { const migrations = new KnexMigrations('test', 'source', 'source', 1024) - expect(await migrations.getLatestMigration()).toBe('2026-10-01-001 add snapshot archive source owners') + expect(await migrations.getLatestMigration()).toBe('2026-10-01-002 add snapshot archive source guards') }) test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts index 15f688836..3a7c22fc6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReaderHttp.test.ts @@ -71,11 +71,11 @@ test.each([StorageClient, StorageMobile])( const open = jest.spyOn(fixture.storage, 'openSnapshotArchiveSource').mockImplementation(async (...args) => { const source = (await originalOpen(...args))! const owned = Reflect.get(fixture.storage, 'snapshotSyncSource') as StorageKnex - const destroy = owned.destroy.bind(owned) - jest.spyOn(owned, 'destroy').mockImplementation(async () => { + const destroy = owned.knex.client.destroyRawConnection.bind(owned.knex.client) + jest.spyOn(owned.knex.client, 'destroyRawConnection').mockImplementation(async connection => { destroying.resolve() await allowDestroy.promise - await destroy() + await destroy(connection) }) const read = source.readPage source.readPage = async (table, cursor, limits) => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.test.ts new file mode 100644 index 000000000..f9da8807f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.test.ts @@ -0,0 +1,487 @@ +import { mkdtemp, readdir, rename, rm, stat, symlink } from 'node:fs/promises' +import filesystem from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex, type Knex } from 'knex' +import { addSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' +import { addSnapshotArchiveRequestTable } from '../../schema/snapshotArchiveRequestMigration' +import { addSnapshotArchiveOwnerTable } from '../../schema/snapshotArchiveOwnerMigration' +import { + addSnapshotArchiveGuardTable, + removeSnapshotArchiveGuardTable +} from '../../schema/snapshotArchiveGuardMigration' +import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' +import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { readGuardedSnapshotArchive, recoverSnapshotArchiveGuards } from './SnapshotArchiveGuard' +import { SnapshotArchiveSourceCleanupError } from './KnexSnapshotArchiveSource' +import * as Backend from './SnapshotArchiveGuardBackend' +import * as Registry from './SnapshotArchiveGuardRegistry' + +const stores: Knex[] = [] +const directories: string[] = [] +function gate() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} +function identity(index = 1): string { + return '02' + index.toString(16).padStart(2, '0').repeat(32) +} +function request(index = 1) { + const fields = { + version: 1 as const, + nonce: index.toString(16).padStart(2, '0').repeat(32), + notAfter: Date.now() + 300000, + maxBytes: 32768 + } + return { ...fields, requestId: snapshotArchiveRequestId(fields) } +} +async function fixture(upgrade = true) { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-guard-')) + directories.push(directory) + const filename = join(directory, 'wallet.sqlite') + const config: Knex.Config = { + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + } + const open = () => { + const value = knex(config) + stores.push(value) + return value + } + const control = open() + await control.raw('PRAGMA journal_mode = WAL') + await addSnapshotArchiveTables(control) + await addSnapshotArchiveRequestTable(control) + await addSnapshotArchiveOwnerTable(control) + if (upgrade) await addSnapshotArchiveGuardTable(control) + await control.schema.createTable('fixture_values', table => { + table.integer('id').primary() + table.string('value') + }) + await control('fixture_values').insert({ id: 1, value: 'before' }) + const requests = new KnexSnapshotArchiveRequestStore(control, true, upgrade) + const recover = async () => { + await recoverSnapshotArchiveGuards(control, config) + await requests.reap() + } + return { control, requests, recover, directory, filename, config, open } +} +afterEach(async () => { + jest.restoreAllMocks() + await Promise.all(stores.splice(0).map(value => value.destroy())) + await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +test('the additive migration preserves unguarded owners and refuses their downgrade', async () => { + const f = await fixture(false) + const input = request() + const claimed = await f.requests.claim(identity(), input) + await addSnapshotArchiveGuardTable(f.control) + await addSnapshotArchiveGuardTable(f.control) + expect(await f.control('snapshot_archive_owner_slots')).toHaveLength(8) + expect(await f.control('snapshot_archive_owners').first()).toMatchObject({ ...claimed.owner, guardVersion: 0 }) + await f.requests.markCancellation(identity(), input) + await f.recover() + expect(await f.control('snapshot_archive_owners')).toHaveLength(1) + await expect(removeSnapshotArchiveGuardTable(f.control)).rejects.toThrow('Drain') + await f.requests.sourceClosed(claimed.owner!) + await removeSnapshotArchiveGuardTable(f.control) + await removeSnapshotArchiveGuardTable(f.control) + expect(await f.control.schema.hasColumn('snapshot_archive_owners', 'guardVersion')).toBe(false) +}) + +test('recovery waits through physical connection close while foreground WAL writes continue', async () => { + const f = await fixture() + const input = request() + const { owner } = await f.requests.claim(identity(), input) + const source = f.open(), + opened = gate(), + stop = gate(), + closing = gate(), + finish = gate() + const destroy = source.client.destroyRawConnection.bind(source.client) + jest.spyOn(source.client, 'destroyRawConnection').mockImplementation(async connection => { + closing.resolve() + await finish.promise + await destroy(connection) + }) + const work = readGuardedSnapshotArchive(f.control, source, owner!, async trx => { + expect((await trx('fixture_values').first()).value).toBe('before') + await expect(trx('fixture_values').update({ value: 'forbidden' })).rejects.toMatchObject({ + code: 'SQLITE_READONLY' + }) + opened.resolve() + await stop.promise + expect((await trx('fixture_values').first()).value).toBe('before') + }) + try { + await opened.promise + await f.requests.markCancellation(identity(), input) + await f.recover() + expect(await f.control('snapshot_archive_owners')).toHaveLength(1) + await f.control('fixture_values').update({ value: 'foreground' }) + stop.resolve() + await closing.promise + await f.recover() + expect((await f.control('snapshot_archive_capacity').first()).archives).toBe(1) + finish.resolve() + await work + await f.recover() + expect(await f.control('snapshot_archive_owners')).toHaveLength(0) + expect((await f.control('snapshot_archive_capacity').first()).archives).toBe(0) + expect((await f.control('fixture_values').first()).value).toBe('foreground') + } finally { + stop.resolve() + finish.resolve() + await work.catch(() => undefined) + } +}) + +test.each(['unbound', 'bound'] as const)( + 'a %s late acquisition cannot read after recovery or release a successor slot', + async phase => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + const entered = gate(), + resume = gate() + if (phase === 'unbound') { + const bind = Registry.bindSnapshotArchiveOwnerGuard + jest.spyOn(Registry, 'bindSnapshotArchiveOwnerGuard').mockImplementationOnce(async (...args) => { + entered.resolve() + await resume.promise + return await bind(...args) + }) + } else { + const guard = Backend.withSnapshotArchiveBackendGuard + jest.spyOn(Backend, 'withSnapshotArchiveBackendGuard').mockImplementationOnce(async (...args) => { + entered.resolve() + await resume.promise + return await guard(...args) + }) + } + let reads = 0 + const work = readGuardedSnapshotArchive(f.control, f.open(), owner!, async () => { + reads++ + }) + void work.catch(() => undefined) + try { + await entered.promise + await f.requests.markCancellation(identity(), input) + await f.recover() + const successor = await f.requests.claim(identity(), request(2)) + resume.resolve() + await expect(work).rejects.toThrow('unavailable') + expect(reads).toBe(0) + await f.requests.sourceClosed(owner!) + expect(await f.control('snapshot_archive_owners').first()).toMatchObject(successor.owner!) + await readGuardedSnapshotArchive(f.control, f.open(), successor.owner!, async trx => { + expect((await trx('fixture_values').first()).value).toBe('before') + }) + await f.requests.sourceClosed(successor.owner!) + } finally { + resume.resolve() + await work.catch(() => undefined) + } + } +) + +test('a missing bound guard fails closed without recreating its inode', async () => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + await readGuardedSnapshotArchive(f.control, f.open(), owner!, async () => undefined) + await f.requests.markCancellation(identity(), input) + const path = `${f.filename}.snapshot-owner-0.sqlite`, + moved = path + '.retained' + const inode = (await stat(path)).ino + await rename(path, moved) + await expect(f.recover()).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(f.directory)).not.toContain('wallet.sqlite.snapshot-owner-0.sqlite') + expect((await f.control('snapshot_archive_capacity').first()).archives).toBe(1) + await rename(moved, path) + await f.recover() + expect((await stat(path)).ino).toBe(inode) + expect((await f.control('snapshot_archive_capacity').first()).archives).toBe(0) +}) + +test('an unproved native close never rolls back the guard or releases source capacity', async () => { + const f = await fixture(), + input = request(), + source = f.open() + const { owner } = await f.requests.claim(identity(), input) + let connection: { open: boolean; close: () => void } | undefined + jest.spyOn(source.client, 'destroyRawConnection').mockImplementation(async value => { + connection = value + }) + try { + await expect( + readGuardedSnapshotArchive(f.control, source, owner!, async trx => { + expect((await trx('fixture_values').first()).value).toBe('before') + }) + ).rejects.toBeInstanceOf(SnapshotArchiveSourceCleanupError) + expect(connection?.open).toBe(true) + await f.requests.markCancellation(identity(), input) + await f.recover() + expect((await f.control('snapshot_archive_capacity').first()).archives).toBe(1) + connection!.close() + await f.recover() + expect((await f.control('snapshot_archive_capacity').first()).archives).toBe(0) + } finally { + if (connection?.open) connection.close() + } +}) + +test('all eight independent source guards preserve old views and stable slot files through reuse', async () => { + const f = await fixture(), + stop = gate(), + work: Promise[] = [] + try { + for (let index = 1; index <= 8; index++) { + const claimed = await f.requests.claim(identity(index), request(index)) + const opened = gate() + work.push( + readGuardedSnapshotArchive(f.control, f.open(), claimed.owner!, async trx => { + expect((await trx('fixture_values').first()).value).toBe('before') + opened.resolve() + await stop.promise + expect((await trx('fixture_values').first()).value).toBe('before') + }) + ) + await opened.promise + } + await expect(f.requests.claim(identity(9), request(9))).rejects.toThrow('capacity') + const files = (await readdir(f.directory)).filter(name => /snapshot-owner-\d.sqlite$/.test(name)).sort() + expect(files).toHaveLength(8) + const inodes = await Promise.all(files.map(async name => (await stat(join(f.directory, name))).ino)) + await f.control('fixture_values').update({ value: 'committed' }) + await f.control('snapshot_archive_requests').update({ expiresAt: 1 }) + await f.recover() + expect(await f.control('snapshot_archive_owners')).toHaveLength(8) + stop.resolve() + await Promise.all(work) + await f.recover() + expect((await f.control('snapshot_archive_capacity').first()).archives).toBe(0) + const successor = await f.requests.claim(identity(), request(10)) + await readGuardedSnapshotArchive(f.control, f.open(), successor.owner!, async trx => { + expect((await trx('fixture_values').first()).value).toBe('committed') + }) + expect(await Promise.all(files.map(async name => (await stat(join(f.directory, name))).ino))).toEqual(inodes) + expect(await f.control('snapshot_archive_owner_slots').whereNotNull('bindingJson')).toHaveLength(8) + } finally { + stop.resolve() + await Promise.allSettled(work) + } +}) + +test.each(['owner', 'slot', 'binding', 'request', 'released', 'state', 'expired'] as const)( + 'the final source check rejects a changed %s before reading wallet data', + async field => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + const guard = Backend.withSnapshotArchiveBackendGuard + jest.spyOn(Backend, 'withSnapshotArchiveBackendGuard').mockImplementationOnce(async (...args) => { + if (field === 'owner') await f.control('snapshot_archive_owners').delete() + else if (field === 'slot') await f.control('snapshot_archive_owners').update({ slot: 1 }) + else if (field === 'binding') + await f.control('snapshot_archive_owner_slots').where({ slot: 0 }).update({ bindingJson: '{}' }) + else if (field === 'request') await f.control('snapshot_archive_requests').delete() + else if (field === 'released') await f.control('snapshot_archive_requests').update({ released: 1 }) + else if (field === 'state') await f.control('snapshot_archive_requests').update({ state: 'ready' }) + else await f.control('snapshot_archive_requests').update({ expiresAt: 1 }) + return await guard(...args) + }) + const read = jest.fn() + await expect(readGuardedSnapshotArchive(f.control, f.open(), owner!, read)).rejects.toThrow('unavailable') + expect(read).not.toHaveBeenCalled() + } +) + +test.each(['invalid-slot', 'missing-slot'] as const)( + 'a corrupt %s cannot choose an unbound backend guard', + async kind => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + if (kind === 'invalid-slot') await f.control('snapshot_archive_owners').update({ slot: 8 }) + else await f.control('snapshot_archive_owner_slots').where({ slot: 0 }).delete() + await expect(Registry.readSnapshotArchiveOwnerGuard(f.control, owner!)).rejects.toThrow('unavailable') + } +) + +test('binding rechecks the owner slot and refuses to replace an established binding', async () => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + const context = await Registry.readSnapshotArchiveOwnerGuard(f.control, owner!) + const verify = jest.fn(async () => undefined) + await f.control('snapshot_archive_owners').update({ slot: 1 }) + await expect(Registry.bindSnapshotArchiveOwnerGuard(f.control, context, 'binding', verify)).rejects.toThrow( + 'unavailable' + ) + await f.control('snapshot_archive_owners').update({ slot: 0 }) + const bound = await Registry.bindSnapshotArchiveOwnerGuard(f.control, context, 'binding', verify) + expect(bound.bindingJson).toBe('binding') + await expect(Registry.bindSnapshotArchiveOwnerGuard(f.control, bound, 'replacement', verify)).rejects.toThrow( + 'unavailable' + ) + expect((await f.control('snapshot_archive_owner_slots').where({ slot: 0 }).first()).bindingJson).toBe('binding') +}) + +test('recovery fences only the exact expired owner and preserves live requests', async () => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + const context = await Registry.readSnapshotArchiveOwnerGuard(f.control, owner!) + const fence = (value = context, acknowledge = false) => + f.control.transaction(trx => Registry.fenceSnapshotArchiveOwnerGuard(trx, value, acknowledge)) + expect(await fence()).toBe(false) + expect(await fence({ ...context, slot: 1 })).toBe(false) + expect(await fence({ ...context, bindingJson: 'different' })).toBe(false) + await f.control('snapshot_archive_requests').where(owner!).update({ expiresAt: 1 }) + expect(await fence()).toBe(true) + expect((await f.control('snapshot_archive_requests').where(owner!).first()).state).toBe('expired') + expect(await f.control('snapshot_archive_owners')).toHaveLength(1) + expect(await fence(context, true)).toBe(true) + expect(await f.control('snapshot_archive_owners')).toHaveLength(0) + expect(await fence(context, true)).toBe(false) +}) + +test.each(['missing', 'released'] as const)('recovery refuses a %s request without releasing its owner', async kind => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + const context = await Registry.readSnapshotArchiveOwnerGuard(f.control, owner!) + if (kind === 'missing') await f.control('snapshot_archive_requests').delete() + else await f.control('snapshot_archive_requests').update({ released: 1 }) + await expect( + f.control.transaction(trx => Registry.fenceSnapshotArchiveOwnerGuard(trx, context, true)) + ).rejects.toThrow('unavailable') + expect(await f.control('snapshot_archive_owners')).toHaveLength(1) +}) + +test('a replaced guard marker or symlink cannot prove recovery of the bound source', async () => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + await readGuardedSnapshotArchive(f.control, f.open(), owner!, async () => undefined) + await f.requests.markCancellation(identity(), input) + const path = `${f.filename}.snapshot-owner-0.sqlite` + const metadata = knex({ ...f.config, connection: { filename: path } }) + try { + await metadata('snapshot_owner_guard').update({ marker: 'invalid' }) + } finally { + await metadata.destroy() + } + await expect(f.recover()).rejects.toThrow('identity changed') + await rename(path, path + '.retained') + await symlink(path + '.retained', path) + await expect(f.recover()).rejects.toThrow('identity changed') + expect(Number((await f.control('snapshot_archive_capacity').first()).archives)).toBe(1) +}) + +test('a marker changed after preparation is rejected on the guarded physical transaction', async () => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + const guard = Backend.withSnapshotArchiveBackendGuard + jest.spyOn(Backend, 'withSnapshotArchiveBackendGuard').mockImplementationOnce(async (...args) => { + const metadata = knex({ ...f.config, connection: { filename: `${f.filename}.snapshot-owner-0.sqlite` } }) + try { + await metadata('snapshot_owner_guard').update({ marker: 'a'.repeat(64) }) + } finally { + await metadata.destroy() + } + return await guard(...args) + }) + const read = jest.fn() + await expect(readGuardedSnapshotArchive(f.control, f.open(), owner!, read)).rejects.toThrow('identity changed') + expect(read).not.toHaveBeenCalled() +}) + +test('SQLite guards require WAL and reject a different physical database', async () => { + const f = await fixture() + await f.control.raw('PRAGMA journal_mode = DELETE') + await expect(Backend.prepareSnapshotArchiveGuardBackend(f.control, 0, null)).rejects.toThrow('identity changed') + await f.control.raw('PRAGMA journal_mode = WAL') + const binding = await Backend.prepareSnapshotArchiveGuardBackend(f.control, 0, null) + const other = await fixture() + await expect(Backend.assertSnapshotArchiveGuardBackend(other.control, binding)).rejects.toThrow('identity changed') +}) + +test('guard creation preserves an independent filesystem failure', async () => { + const f = await fixture() + const failure = Object.assign(new Error('fixture storage full'), { code: 'ENOSPC' }) + jest.spyOn(filesystem, 'open').mockRejectedValueOnce(failure) + await expect(Backend.prepareSnapshotArchiveGuardBackend(f.control, 0, null)).rejects.toBe(failure) +}) + +test('guard preparation rejects an inode replaced during metadata inspection', async () => { + const f = await fixture() + await Backend.prepareSnapshotArchiveGuardBackend(f.control, 0, null) + const path = `${f.filename}.snapshot-owner-0.sqlite` + const main = await filesystem.lstat(f.filename, { bigint: true }) + const before = await filesystem.lstat(path, { bigint: true }) + const after = await filesystem.lstat(path, { bigint: true }) + after.ino += 1n + jest.spyOn(filesystem, 'lstat').mockResolvedValueOnce(main).mockResolvedValueOnce(before).mockResolvedValueOnce(after) + await expect(Backend.prepareSnapshotArchiveGuardBackend(f.control, 0, null)).rejects.toThrow('identity changed') +}) + +test('a disconnected main database cannot select a guard from configuration alone', async () => { + const f = await fixture() + const raw = f.control.raw.bind(f.control) + const probe = { + client: f.control.client, + raw: (sql: string) => (sql === 'PRAGMA database_list' ? raw('SELECT 1 AS absent') : raw(sql)) + } as unknown as Knex + await expect(Backend.prepareSnapshotArchiveGuardBackend(probe, 0, null)).rejects.toThrow('identity changed') +}) + +test('physical connection rechecks both main and guard inode identity', async () => { + const f = await fixture() + const binding = await Backend.prepareSnapshotArchiveGuardBackend(f.control, 0, null) + const main = await filesystem.lstat(f.filename, { bigint: true }) + main.ino += 1n + const changedMain = jest.spyOn(filesystem, 'lstat').mockResolvedValueOnce(main) + await expect(Backend.assertSnapshotArchiveGuardBackend(f.control, binding)).rejects.toThrow('identity changed') + changedMain.mockRestore() + const mainActual = await filesystem.lstat(f.filename, { bigint: true }) + const guard = await filesystem.lstat(`${f.filename}.snapshot-owner-0.sqlite`, { bigint: true }) + guard.ino += 1n + jest.spyOn(filesystem, 'lstat').mockResolvedValueOnce(mainActual).mockResolvedValueOnce(guard) + await expect(Backend.assertSnapshotArchiveGuardBackend(f.control, binding)).rejects.toThrow('identity changed') +}) + +test('an absent owner cannot enter backend preparation', async () => { + const f = await fixture() + await expect( + Registry.readSnapshotArchiveOwnerGuard(f.control, { + identityKey: identity(), + requestId: 'a'.repeat(64), + claimToken: 'b'.repeat(64) + }) + ).rejects.toThrow('unavailable') +}) + +test('a concurrent exact acknowledgement makes an old recovery proof harmless', async () => { + const f = await fixture(), + input = request() + const { owner } = await f.requests.claim(identity(), input) + await readGuardedSnapshotArchive(f.control, f.open(), owner!, async () => undefined) + await f.requests.markCancellation(identity(), input) + const guard = Backend.withSnapshotArchiveBackendGuard + jest.spyOn(Backend, 'withSnapshotArchiveBackendGuard').mockImplementationOnce(async (...args) => { + await f.requests.sourceClosed(owner!) + return await guard(...args) + }) + await f.recover() + expect(await f.control('snapshot_archive_owners')).toHaveLength(0) + expect(Number((await f.control('snapshot_archive_capacity').first()).archives)).toBe(0) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.ts new file mode 100644 index 000000000..730c27332 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.ts @@ -0,0 +1,68 @@ +import { knex as createKnex, type Knex } from 'knex' +import { runInSeries } from '../../../utility/runInSeries' +import type { SnapshotArchiveRequestOwner } from './SnapshotArchiveRequest' +import { + assertSnapshotArchiveGuardBackend, + prepareSnapshotArchiveGuardBackend, + SnapshotArchiveGuardBusyError, + withSnapshotArchiveBackendGuard +} from './SnapshotArchiveGuardBackend' +import { + assertSnapshotArchiveGuardOwner, + bindSnapshotArchiveOwnerGuard, + expiredSnapshotArchiveOwnerGuards, + fenceSnapshotArchiveOwnerGuard, + readSnapshotArchiveOwnerGuard +} from './SnapshotArchiveGuardRegistry' + +/** Prepare outside the writer lock; bind and recheck the exact owner before taking its view. */ +export async function readGuardedSnapshotArchive( + control: Knex, + source: Knex, + owner: SnapshotArchiveRequestOwner, + read: (trx: Knex.Transaction) => Promise +): Promise { + const expected = await readSnapshotArchiveOwnerGuard(control, owner) + const binding = await prepareSnapshotArchiveGuardBackend(source, expected.slot, expected.bindingJson) + const context = await bindSnapshotArchiveOwnerGuard(control, expected, JSON.stringify(binding), trx => + assertSnapshotArchiveGuardBackend(trx, binding) + ) + return await withSnapshotArchiveBackendGuard(source, binding, async trx => { + await assertSnapshotArchiveGuardOwner(trx, context) + return await read(trx) + }) +} + +/** The provider serializes this operation; each proof pool closes before quota can be released. */ +export async function recoverSnapshotArchiveGuards(control: Knex, config: Knex.Config): Promise { + const owners = await expiredSnapshotArchiveOwnerGuards(control) + await runInSeries(owners, async context => { + if (context.bindingJson === null) { + // Recheck null under the capacity lock: a concurrent preparer may have + // bound the slot since enumeration. No guard may open before that bind. + await control.transaction(trx => fenceSnapshotArchiveOwnerGuard(trx, context, true)) + return + } + const proof = createKnex(config) + let fenced = false + try { + const binding = await prepareSnapshotArchiveGuardBackend(proof, context.slot, context.bindingJson) + fenced = await withSnapshotArchiveBackendGuard(proof, binding, async () => { + return await control.transaction(async trx => { + await assertSnapshotArchiveGuardBackend(trx, binding) + return await fenceSnapshotArchiveOwnerGuard(trx, context) + }) + }) + if (fenced) { + await control.transaction(async trx => { + await assertSnapshotArchiveGuardBackend(trx, binding) + await fenceSnapshotArchiveOwnerGuard(trx, context, true) + }) + } + } catch (error) { + if (!(error instanceof SnapshotArchiveGuardBusyError)) throw error + } finally { + await proof.destroy() + } + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.test.ts new file mode 100644 index 000000000..c71ce9124 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.test.ts @@ -0,0 +1,227 @@ +import { EventEmitter } from 'node:events' +import type { Knex } from 'knex' +import { SnapshotArchiveSourceCleanupError } from './KnexSnapshotArchiveSource' +import { + assertSnapshotArchiveGuardBackend, + prepareSnapshotArchiveGuardBackend, + SnapshotArchiveGuardBusyError, + withSnapshotArchiveBackendGuard, + type SnapshotArchiveGuardBinding +} from './SnapshotArchiveGuardBackend' + +const uuid = '12345678-1234-1234-1234-123456789abc' +const identity = { serverUuid: uuid, databaseName: 'fixture' } +function fixture() { + const stream = Object.assign(new EventEmitter(), { destroyed: false, closed: false }) + const connection = { stream } + const close = () => { + stream.destroyed = true + stream.closed = true + stream.emit('close') + } + const reply = jest.fn((sql: string): unknown => { + if (sql.startsWith('SELECT @@')) return [[identity]] + if (sql.startsWith('SELECT GET_LOCK')) return [[{ acquired: 1 }]] + return [] + }) + const queries: Array<{ sql: string; values: unknown; connection?: unknown }> = [] + const raw = jest.fn((sql: string, values?: unknown) => { + const entry = { sql, values, connection: undefined as unknown } + queries.push(entry) + return Object.assign( + Promise.resolve().then(() => reply(sql)), + { + connection(value: unknown) { + entry.connection = value + return this + } + } + ) + }) + const trx = { executionPromise: Promise.resolve(), rollback: jest.fn(async () => undefined) } + const destroy = jest.fn(async () => close()) + const client = { + config: { client: 'mysql2' }, + acquireConnection: jest.fn(async () => connection), + releaseConnection: jest.fn(async () => undefined) + } + const transaction = jest.fn(async () => trx) + const k = { raw, client, transaction, destroy } as unknown as Knex + return { k, stream, close, connection, reply, queries, trx, destroy, client, transaction } +} + +test('MySQL guard namespaces bind the actual server, database and bounded slot', async () => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + expect(binding).toMatchObject({ version: 1, kind: 'mysql', serverUuid: uuid, database: 'fixture' }) + if (binding.kind !== 'mysql') throw new Error('Expected MySQL binding') + expect(binding.lock).toMatch(/^wallet-snapshot-v1:[0-9a-f]{40}:0$/) + expect(binding.lock.length).toBeLessThanOrEqual(64) + expect(await prepareSnapshotArchiveGuardBackend(f.k, 0, JSON.stringify(binding))).toEqual(binding) + expect(await prepareSnapshotArchiveGuardBackend(f.k, 7, null)).not.toEqual(binding) + for (const slot of [-1, 8, 0.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) + await expect(prepareSnapshotArchiveGuardBackend(f.k, slot, null)).rejects.toThrow('identity changed') + await expect(prepareSnapshotArchiveGuardBackend(f.k, 1, JSON.stringify(binding))).rejects.toThrow('identity changed') +}) + +test.each([ + undefined, + { serverUuid: null, databaseName: 'fixture' }, + { serverUuid: 'not-a-server', databaseName: 'fixture' }, + { serverUuid: uuid, databaseName: undefined }, + { serverUuid: uuid, databaseName: '' }, + { serverUuid: uuid, databaseName: 'x'.repeat(65) } +])('invalid MySQL identity %j refuses a guard', async row => { + const f = fixture() + f.reply.mockReturnValue([[row]]) + await expect(prepareSnapshotArchiveGuardBackend(f.k, 0, null)).rejects.toThrow('identity changed') +}) + +test.each(['server', 'database'] as const)( + 'physical %s mismatch refuses reads and closes its private connection', + async field => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + f.reply.mockReturnValue([ + [field === 'server' ? { ...identity, serverUuid: 'a'.repeat(36) } : { ...identity, databaseName: 'other' }] + ]) + const read = jest.fn() + await expect(withSnapshotArchiveBackendGuard(f.k, binding, read)).rejects.toThrow('identity changed') + expect(read).not.toHaveBeenCalled() + expect(f.transaction).not.toHaveBeenCalled() + expect(f.queries.at(-1)?.connection).toBe(f.connection) + expect(f.stream.closed).toBe(true) + expect(f.client.releaseConnection).toHaveBeenCalledWith(f.connection) + } +) + +test.each([0, null, undefined, 2])('MySQL GET_LOCK result %s never admits a read', async acquired => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + f.reply.mockImplementation(sql => (sql.startsWith('SELECT @@') ? [[identity]] : [[{ acquired }]])) + const read = jest.fn() + const work = withSnapshotArchiveBackendGuard(f.k, binding, read) + if (acquired === 0) await expect(work).rejects.toBeInstanceOf(SnapshotArchiveGuardBusyError) + else await expect(work).rejects.toThrow('identity changed') + expect(read).not.toHaveBeenCalled() + expect(f.transaction).not.toHaveBeenCalled() + expect(f.stream.closed).toBe(true) +}) + +test('a graceful quit and a destroyed socket still wait for physical close before rollback or success', async () => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + let quit!: () => void + const quitting = new Promise(resolve => { + quit = resolve + }) + f.destroy.mockImplementation(async () => { + f.stream.destroyed = true + quit() + }) + let settled = false + const work = withSnapshotArchiveBackendGuard(f.k, binding, async trx => { + expect(trx).toBe(f.trx) + return 42 + }).finally(() => { + settled = true + }) + await quitting + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + expect(f.trx.rollback).not.toHaveBeenCalled() + expect(f.stream.listenerCount('close')).toBe(1) + expect(f.queries.slice(1).map(query => query.sql)).toEqual([ + 'SELECT @@server_uuid AS serverUuid, DATABASE() AS databaseName', + 'SELECT GET_LOCK(?, 0) AS acquired', + 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY' + ]) + expect(f.queries.slice(1).every(query => query.connection === f.connection)).toBe(true) + expect(f.queries[2].values).toEqual([(binding as Extract).lock]) + expect(f.transaction).toHaveBeenCalledWith({ connection: f.connection }) + f.close() + await expect(work).resolves.toBe(42) + expect(f.trx.rollback).toHaveBeenCalledTimes(1) + expect(f.stream.listenerCount('close')).toBe(0) +}) + +test('a socket destroyed before cleanup still waits for its pending close event', async () => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + let cleanup!: () => void + const started = new Promise(resolve => { + cleanup = resolve + }) + f.destroy.mockImplementation(async () => { + cleanup() + }) + const work = withSnapshotArchiveBackendGuard(f.k, binding, async () => { + f.stream.destroyed = true + return 'read' + }) + await started + expect(f.stream.listenerCount('close')).toBe(1) + expect(f.trx.rollback).not.toHaveBeenCalled() + f.close() + await expect(work).resolves.toBe('read') +}) + +test.each([new Error('read failed'), undefined])( + 'proved close preserves the exact independent read failure %s', + async failure => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + f.trx.rollback.mockRejectedValue(new Error('connection already closed')) + await expect( + withSnapshotArchiveBackendGuard(f.k, binding, async () => { + throw failure + }) + ).rejects.toBe(failure) + expect(f.stream.closed).toBe(true) + expect(f.stream.listenerCount('close')).toBe(0) + } +) + +test.each(['destroy', 'release'] as const)( + 'a %s failure retains the manual transaction and reports cleanup failure', + async phase => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + const failure = new Error('native cleanup failed') + f.destroy.mockImplementation(async () => undefined) + if (phase === 'destroy') f.destroy.mockRejectedValue(failure) + else f.client.releaseConnection.mockRejectedValue(failure) + try { + await expect(withSnapshotArchiveBackendGuard(f.k, binding, async () => 1)).rejects.toBeInstanceOf( + SnapshotArchiveSourceCleanupError + ) + expect(f.trx.rollback).not.toHaveBeenCalled() + expect(f.stream.closed).toBe(false) + expect(f.stream.listenerCount('close')).toBe(0) + } finally { + f.close() + } + } +) + +test('a previously closed socket does not need another close event', async () => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + f.destroy.mockImplementation(async () => undefined) + await expect( + withSnapshotArchiveBackendGuard(f.k, binding, async () => { + f.close() + return 3 + }) + ).resolves.toBe(3) + expect(f.stream.listenerCount('close')).toBe(0) + await assertSnapshotArchiveGuardBackend(f.k, binding) + expect(f.queries.at(-1)?.connection).toBeUndefined() +}) + +test('unsupported backend refuses before acquiring a source', async () => { + const f = fixture() + f.client.config.client = 'other' + await expect(prepareSnapshotArchiveGuardBackend(f.k, 0, null)).rejects.toThrow('require better-sqlite3 WAL or MySQL') + expect(f.client.acquireConnection).not.toHaveBeenCalled() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts new file mode 100644 index 000000000..97d88fc37 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts @@ -0,0 +1,254 @@ +import { createHash, randomBytes } from 'node:crypto' +import { lstat, open, realpath } from 'node:fs/promises' +import type { Duplex } from 'node:stream' +import { knex as createKnex, type Knex } from 'knex' +import { WERR_INVALID_OPERATION, WERR_NOT_IMPLEMENTED } from '../../../sdk/WERR_errors' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { SnapshotArchiveSourceCleanupError } from './KnexSnapshotArchiveSource' +import { snapshotArchiveLimits } from './SnapshotArchive' + +interface FileIdentity { + path: string + device: string + inode: string +} + +interface SQLiteGuardBinding { + version: 1 + kind: 'sqlite' + database: FileIdentity + guard: FileIdentity & { marker: string } +} + +interface MySQLGuardBinding { + version: 1 + kind: 'mysql' + serverUuid: string + database: string + lock: string +} + +export type SnapshotArchiveGuardBinding = SQLiteGuardBinding | MySQLGuardBinding + +export class SnapshotArchiveGuardBusyError extends SnapshotResourceLimitError { + constructor() { + super('Snapshot archive source guard is occupied') + } +} + +function unavailable(): never { + throw new WERR_INVALID_OPERATION('Snapshot archive source backend or guard identity changed') +} + +async function fileIdentity(path: string): Promise { + const info = await lstat(path, { bigint: true }) + if (!info.isFile()) unavailable() + return { path, device: info.dev.toString(), inode: info.ino.toString() } +} + +function sqliteGuardPool(filename: string, existing: boolean): Knex { + // Knex's option declaration omits this supported better-sqlite3 option. + const options = { fileMustExist: true, readonly: existing } + return createKnex({ + client: 'better-sqlite3', + connection: { filename, options }, + useNullAsDefault: true, + pool: { min: 0, max: 1 }, + acquireConnectionTimeout: 5000 + }) +} + +async function guardFile(database: FileIdentity, slot: number, existing: boolean): Promise { + const path = `${database.path}.snapshot-owner-${slot}.sqlite` + if (!existing) { + try { + const file = await open(path, 'wx', 0o600) + await file.close() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + } + const before = await fileIdentity(path) + const guard = sqliteGuardPool(path, existing) + try { + if (!existing) { + await guard.raw('PRAGMA busy_timeout = 0') + await guard.transaction(async trx => { + await trx.raw( + 'CREATE TABLE IF NOT EXISTS snapshot_owner_guard (id INTEGER PRIMARY KEY, marker TEXT NOT NULL, held INTEGER NOT NULL)' + ) + await trx('snapshot_owner_guard') + .insert({ id: 1, marker: randomBytes(32).toString('hex'), held: 0 }) + .onConflict('id') + .ignore() + }) + } + const row: { marker?: unknown } | undefined = await guard('snapshot_owner_guard').where({ id: 1 }).first('marker') + if (typeof row?.marker !== 'string' || !/^[0-9a-f]{64}$/.test(row.marker)) unavailable() + if (JSON.stringify(await fileIdentity(path)) !== JSON.stringify(before)) unavailable() + return { version: 1, kind: 'sqlite', database, guard: { ...before, marker: row.marker } } + } finally { + await guard.destroy() + } +} + +async function mysqlIdentity(k: Knex, connection?: unknown): Promise<{ serverUuid: string; database: string }> { + const query = k.raw('SELECT @@server_uuid AS serverUuid, DATABASE() AS databaseName') + if (connection !== undefined) void query.connection(connection) + const [rows]: Array> = await query + const row = rows[0] + if ( + typeof row?.serverUuid !== 'string' || + !/^[0-9a-f-]{36}$/i.test(row.serverUuid) || + typeof row.databaseName !== 'string' || + row.databaseName.length < 1 || + row.databaseName.length > 64 + ) + unavailable() + return { serverUuid: row.serverUuid, database: row.databaseName } +} + +/** No main writer lock is held while probing files or opening a guard metadata connection. */ +export async function prepareSnapshotArchiveGuardBackend( + k: Knex, + slot: number, + existing: string | null +): Promise { + if (!Number.isSafeInteger(slot) || slot < 0 || slot >= snapshotArchiveLimits.archives) unavailable() + let binding: SnapshotArchiveGuardBinding + if (String(k.client.config.client).includes('mysql')) { + const identity = await mysqlIdentity(k) + const namespace = createHash('sha256').update(JSON.stringify(identity)).digest('hex').slice(0, 40) + binding = { version: 1, kind: 'mysql', ...identity, lock: `wallet-snapshot-v1:${namespace}:${slot}` } + } else if (k.client.config.client === 'better-sqlite3') { + const modes: Array<{ journal_mode: string }> = await k.raw('PRAGMA journal_mode') + if (modes[0]?.journal_mode.toLowerCase() !== 'wal') unavailable() + const rows: Array<{ name: string; file: string }> = await k.raw('PRAGMA database_list') + const filename = rows.find(row => row.name === 'main')?.file + if (typeof filename !== 'string' || filename.length === 0) unavailable() + const database = await fileIdentity(await realpath(filename)) + binding = await guardFile(database, slot, existing !== null) + } else { + throw new WERR_NOT_IMPLEMENTED('Snapshot archive source guards require better-sqlite3 WAL or MySQL') + } + if (existing !== null && JSON.stringify(binding) !== existing) unavailable() + return binding +} + +/** Recheck the physical connection, not merely the configured address or an earlier pooled query. */ +export async function assertSnapshotArchiveGuardBackend( + k: Knex, + binding: SnapshotArchiveGuardBinding, + connection?: unknown +): Promise { + if (binding.kind === 'mysql') { + const actual = await mysqlIdentity(k, connection) + if (actual.serverUuid !== binding.serverUuid || actual.database !== binding.database) unavailable() + } else { + const query = k.raw('PRAGMA database_list') + if (connection !== undefined) void query.connection(connection) + const rows: Array<{ name: string; file: string }> = await query + const filename = rows.find(row => row.name === 'main')?.file + if (typeof filename !== 'string' || (await realpath(filename)) !== binding.database.path) unavailable() + if (JSON.stringify(await fileIdentity(binding.database.path)) !== JSON.stringify(binding.database)) unavailable() + const { marker: _marker, ...guard } = binding.guard + if (JSON.stringify(await fileIdentity(guard.path)) !== JSON.stringify(guard)) unavailable() + } +} + +async function attachGuard(k: Knex, connection: unknown, binding: SnapshotArchiveGuardBinding): Promise { + await assertSnapshotArchiveGuardBackend(k, binding, connection) + if (binding.kind === 'mysql') { + const [rows]: Array> = await k + .raw('SELECT GET_LOCK(?, 0) AS acquired', [binding.lock]) + .connection(connection) + if (rows[0]?.acquired === 0) throw new SnapshotArchiveGuardBusyError() + if (rows[0]?.acquired !== 1) unavailable() + await k.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY').connection(connection) + } else { + await k.raw('ATTACH DATABASE ? AS snapshot_owner', [binding.guard.path]).connection(connection) + await k.raw('PRAGMA busy_timeout = 0').connection(connection) + } +} + +async function holdSQLiteGuard(trx: Knex, binding: SQLiteGuardBinding): Promise { + try { + const changed = await trx('snapshot_owner.snapshot_owner_guard') + .where({ id: 1, marker: binding.guard.marker }) + .update({ held: 1 }) + if (changed !== 1) unavailable() + // The private connection only reads the wallet after establishing its guard. + await trx.raw('PRAGMA query_only = ON') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'SQLITE_BUSY') throw new SnapshotArchiveGuardBusyError() + throw error + } +} + +/** This pool is exclusively owned by this operation; no connection is returned for reuse. */ +async function closeGuardPool(k: Knex, connection: { open?: boolean; stream?: Duplex }): Promise { + const stream = connection.stream + let closedListener: (() => void) | undefined + const nativeClose = + stream === undefined || stream.closed + ? undefined + : new Promise(resolve => { + closedListener = resolve + stream.once('close', resolve) + }) + try { + const destruction = k.destroy() + const settled = await Promise.allSettled([destruction, k.client.releaseConnection(connection)]) + for (const result of settled) if (result.status === 'rejected') throw result.reason + // mysql2's graceful-quit callback precedes the socket's physical close. + await nativeClose + const closed = String(k.client.config.client).includes('mysql') + ? connection.stream?.closed === true + : connection.open === false + if (closed !== true) throw new Error('Snapshot archive source connection did not close') + } catch (error) { + throw new SnapshotArchiveSourceCleanupError(error) + } finally { + if (closedListener !== undefined) stream!.removeListener('close', closedListener) + } +} + +/** + * Keep the same-connection guard until physical destruction. SQLite's normal + * COMMIT would release it too early, so the private pool closes the still-open + * read transaction. Knex then settles its expected closed-connection rollback; + * only proved physical closure can supersede that internal completion result. + */ +export async function withSnapshotArchiveBackendGuard( + k: Knex, + binding: SnapshotArchiveGuardBinding, + read: (trx: Knex.Transaction) => Promise +): Promise { + const connection = await k.client.acquireConnection() + let released = false + const close = async (): Promise => { + released = true + await closeGuardPool(k, connection) + } + try { + await attachGuard(k, connection, binding) + const trx = await k.transaction({ connection }) + void trx.executionPromise.catch(() => undefined) + let outcome: { ok: true; value: T } | { ok: false; error: unknown } + try { + if (binding.kind === 'sqlite') await holdSQLiteGuard(trx, binding) + outcome = { ok: true, value: await read(trx) } + } catch (error) { + outcome = { ok: false, error } + } + // A failed physical close deliberately leaves this manual transaction and + // its guard held. Automatic rollback would falsely prove source cleanup. + await close() + await trx.rollback().catch(() => undefined) + await trx.executionPromise.catch(() => undefined) + if (!outcome.ok) throw outcome.error + return outcome.value + } finally { + if (!released) await close() + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts new file mode 100644 index 000000000..5d4c3ed44 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts @@ -0,0 +1,130 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { snapshotArchiveLimits } from './SnapshotArchive' +import type { SnapshotArchiveRequestOwner } from './SnapshotArchiveRequest' +import { lockSnapshotArchiveCapacity, snapshotArchiveDatabaseNow } from './SnapshotArchiveSql' + +export interface SnapshotArchiveOwnerGuard { + owner: SnapshotArchiveRequestOwner + slot: number + bindingJson: string | null +} + +interface RequestState { + state: string + expiresAt: number | string + released: number | boolean +} + +const terminal = ['closed', 'failed', 'expired', 'resource-limited'] + +function unavailable(): never { + throw new WERR_INVALID_OPERATION('Snapshot archive source guard is unavailable') +} + +async function ownerGuard(k: Knex, owner: SnapshotArchiveRequestOwner): Promise { + const row: { slot: number } | undefined = await k('snapshot_archive_owners') + .where({ ...owner, guardVersion: 1 }) + .first('slot') + if (row === undefined) return undefined + if (!Number.isSafeInteger(row.slot) || row.slot < 0 || row.slot >= snapshotArchiveLimits.archives) unavailable() + const slot: { bindingJson: string | null } | undefined = await k('snapshot_archive_owner_slots') + .where({ slot: row.slot }) + .first('bindingJson') + if (slot === undefined || (slot.bindingJson !== null && typeof slot.bindingJson !== 'string')) unavailable() + return { owner: { ...owner }, slot: row.slot, bindingJson: slot.bindingJson } +} + +/** The source calls this only after acquiring the backend guard, before any wallet-data read. */ +export async function assertSnapshotArchiveGuardOwner(k: Knex, expected: SnapshotArchiveOwnerGuard): Promise { + const actual = await ownerGuard(k, expected.owner) + const request: RequestState | undefined = await k('snapshot_archive_requests').where(expected.owner).first() + if ( + actual === undefined || + actual.slot !== expected.slot || + actual.bindingJson !== expected.bindingJson || + request === undefined || + request.released || + !['claimed', 'capturing'].includes(request.state) || + Number(request.expiresAt) <= (await snapshotArchiveDatabaseNow(k)) + ) + unavailable() +} + +/** Leave the main writer lock before filesystem or backend preparation. */ +export async function readSnapshotArchiveOwnerGuard( + knex: Knex, + input: SnapshotArchiveRequestOwner +): Promise { + const owner = { ...input } + return await knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + const context = await ownerGuard(trx, owner) + if (context === undefined) unavailable() + await assertSnapshotArchiveGuardOwner(trx, context) + return context + }) +} + +/** Fixed slot bindings survive owner release; delayed claimants cannot select a new guard. */ +export async function bindSnapshotArchiveOwnerGuard( + knex: Knex, + context: SnapshotArchiveOwnerGuard, + bindingJson: string, + verifyBackend: (trx: Knex) => Promise +): Promise { + return await knex.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + await verifyBackend(trx) + const current = await ownerGuard(trx, context.owner) + if (current === undefined || current.slot !== context.slot) unavailable() + await assertSnapshotArchiveGuardOwner(trx, current) + if (current.bindingJson !== null && current.bindingJson !== bindingJson) unavailable() + await trx('snapshot_archive_owner_slots').where({ slot: current.slot }).update({ bindingJson }) + return { ...current, bindingJson } + }) +} + +/** At most eight owners; older unguarded candidates remain reserved for explicit cleanup. */ +export async function expiredSnapshotArchiveOwnerGuards(knex: Knex): Promise { + const now = await snapshotArchiveDatabaseNow(knex) + const rows: Array = await knex( + 'snapshot_archive_owners as owner' + ) + .join('snapshot_archive_requests as request', function () { + this.on('owner.identityKey', 'request.identityKey') + .andOn('owner.requestId', 'request.requestId') + .andOn('owner.claimToken', 'request.claimToken') + }) + .join('snapshot_archive_owner_slots as slot', 'slot.slot', 'owner.slot') + .where('owner.guardVersion', 1) + .where(query => { + void query.where('request.expiresAt', '<=', now).orWhereIn('request.state', terminal) + }) + .select('owner.identityKey', 'owner.requestId', 'owner.claimToken', 'owner.slot', 'slot.bindingJson') + .limit(snapshotArchiveLimits.archives) + return rows.map(({ slot, bindingJson, ...owner }) => ({ owner, slot, bindingJson })) +} + +/** Caller holds the backend guard, or proves the binding is still null under this lock. */ +export async function fenceSnapshotArchiveOwnerGuard( + k: Knex, + context: SnapshotArchiveOwnerGuard, + acknowledge = false +): Promise { + await lockSnapshotArchiveCapacity(k) + const actual = await ownerGuard(k, context.owner) + if (actual === undefined || actual.slot !== context.slot || actual.bindingJson !== context.bindingJson) return false + const request: RequestState | undefined = await k('snapshot_archive_requests').where(context.owner).first() + if (request === undefined || request.released) unavailable() + const expired = Number(request.expiresAt) <= (await snapshotArchiveDatabaseNow(k)) + if (!expired && !terminal.includes(request.state)) return false + if (!terminal.includes(request.state)) { + await k('snapshot_archive_requests').where(context.owner).update({ state: 'expired' }) + } + if (acknowledge) + await k('snapshot_archive_owners') + .where({ ...context.owner, slot: context.slot }) + .delete() + return true +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts index 9b5f19450..a3f428f31 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts @@ -33,7 +33,7 @@ test.each([StorageClient, StorageMobile])( expect(storage.getSettings()).not.toHaveProperty('snapshotArchive') let transport = (await client.getSnapshotArchiveTransport(identityKey))! const offer = await transport.offer() - expect(offer.sourceSchema).toBe('2026-10-01-001 add snapshot archive source owners') + expect(offer.sourceSchema).toBe('2026-10-01-002 add snapshot archive source guards') expect(Math.abs(offer.serverTime - Date.now())).toBeLessThan(5000) const fields = { version: 1 as const, @@ -177,8 +177,8 @@ test('server close fences admission and awaits opening capture, physical pool cl const request = { ...fields, requestId: snapshotArchiveRequestId(fields) } const entered = gate() const original = fixture.storage.openSnapshotArchiveSource.bind(fixture.storage) - jest.spyOn(fixture.storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options) => { - const source = await original(key, { ...options, signal: undefined }) + jest.spyOn(fixture.storage, 'openSnapshotArchiveSource').mockImplementation(async (key, options, owner) => { + const source = await original(key, { ...options, signal: undefined }, owner) entered.resolve() await allowOpen.promise return source diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts index 33c1dcf00..d598ef769 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveOwner.ts @@ -8,14 +8,18 @@ const table = 'snapshot_archive_owners' export { SnapshotArchiveCleanupPendingError } from './SnapshotArchiveCleanup' /** Call only inside the shared capacity-locked claim transaction. */ -export async function reserveSnapshotArchiveOwner(k: Knex, owner: SnapshotArchiveRequestOwner): Promise { +export async function reserveSnapshotArchiveOwner( + k: Knex, + owner: SnapshotArchiveRequestOwner, + guarded = false +): Promise { const rows: Array<{ slot: number }> = await k(table).select('slot').limit(snapshotArchiveLimits.archives) const occupied = new Set(rows.map(row => row.slot)) const slot = Array.from({ length: snapshotArchiveLimits.archives }, (_, index) => index).find( index => !occupied.has(index) ) if (slot === undefined) throw new SnapshotArchiveAdmissionLimitError('Snapshot archive source capacity is occupied') - await k(table).insert({ slot, ...owner, archiveId: null }) + await k(table).insert({ slot, ...owner, archiveId: null, ...(guarded ? { guardVersion: 1 } : {}) }) } /** Associate the source with its archive in the same transaction as begin(). */ diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts index 9aca4ef67..308805aa4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts @@ -7,6 +7,9 @@ import { knex } from 'knex' import { addSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' import { addSnapshotArchiveRequestTable } from '../../schema/snapshotArchiveRequestMigration' import { addSnapshotArchiveOwnerTable } from '../../schema/snapshotArchiveOwnerMigration' +import { addSnapshotArchiveGuardTable } from '../../schema/snapshotArchiveGuardMigration' +import { readGuardedSnapshotArchive, recoverSnapshotArchiveGuards } from './SnapshotArchiveGuard' +import * as ArchiveSql from './SnapshotArchiveSql' import { SnapshotArchiveCleanupPendingError } from './SnapshotArchiveOwner' import { KnexSnapshotArchiveStore } from './KnexSnapshotArchiveStore' import { KnexSnapshotArchiveRequestStore } from './KnexSnapshotArchiveRequestStore' @@ -22,6 +25,121 @@ fc.configureGlobal({ ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) }) +test('generated guarded-owner schedules keep quota through native close and fence each successor claim', async () => { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-guard-property-')) + const config = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'guards.sqlite') }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + } + const db = knex(config) + const peer = knex(config) + const requests = new KnexSnapshotArchiveRequestStore(db, true, true) + const replacement = new KnexSnapshotArchiveRequestStore(peer, true, true) + const databaseNow = ArchiveSql.snapshotArchiveDatabaseNow + let expiredAt: number | undefined + const clock = jest + .spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow') + .mockImplementation(async k => expiredAt ?? (await databaseNow(k))) + const recover = async () => { + await recoverSnapshotArchiveGuards(peer, config) + await replacement.reap() + } + const gate = () => { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } + } + try { + await db.raw('PRAGMA journal_mode = WAL') + await addSnapshotArchiveTables(db) + await addSnapshotArchiveRequestTable(db) + await addSnapshotArchiveOwnerTable(db) + await addSnapshotArchiveGuardTable(db) + await db.schema.createTable('guard_values', table => { + table.integer('id').primary() + table.integer('value') + }) + await db('guard_values').insert({ id: 1, value: 0 }) + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: 1000000 }), + fc.boolean(), + fc.array(fc.constantFrom('recover', 'wrong-ack', 'write'), { minLength: 0, maxLength: 4 }), + async (value, expire, schedule) => { + const identityKey = '02' + '77'.repeat(32) + const offered = (await requests.offer(identityKey, { lifetimeMs: 300000, maxBytes: 32768 }))! + const { owner } = await requests.claimReader(identityKey, offered.request) + await db('guard_values').update({ value }) + const source = knex(config) + const opened = gate(), + stop = gate(), + closing = gate(), + finish = gate() + const destroy = source.client.destroyRawConnection.bind(source.client) + source.client.destroyRawConnection = async connection => { + closing.resolve() + await finish.promise + await destroy(connection) + } + const work = readGuardedSnapshotArchive(db, source, owner!, async trx => { + expect((await trx('guard_values').first()).value).toBe(value) + opened.resolve() + await stop.promise + expect((await trx('guard_values').first()).value).toBe(value) + }) + void work.catch(() => undefined) + try { + await opened.promise + if (expire) expiredAt = offered.request.notAfter + else await replacement.markReaderCancellation(identityKey, offered.request) + for (const action of schedule) { + if (action === 'recover') await recover() + else if (action === 'wrong-ack') await replacement.sourceClosed({ ...owner!, claimToken: 'incorrect' }) + else await peer('guard_values').update({ value: value + 1 }) + expect(Number((await db('snapshot_archive_capacity').first()).archives)).toBe(1) + } + stop.resolve() + await closing.promise + await recover() + expect(await db('snapshot_archive_owners').first()).toMatchObject(owner!) + expect(Number((await db('snapshot_archive_capacity').first()).archives)).toBe(1) + finish.resolve() + await work + await recover() + expect(await db('snapshot_archive_owners')).toHaveLength(0) + expect(Number((await db('snapshot_archive_capacity').first()).archives)).toBe(0) + await expect(requests.claimReader(identityKey, offered.request)).rejects.toThrow( + expire ? 'Invalid snapshot archive reader request' : 'unavailable' + ) + expect((await db('guard_values').first()).value).toBe(schedule.includes('write') ? value + 1 : value) + const next = (await requests.offer(identityKey, { lifetimeMs: 300000, maxBytes: 32768 }))! + const successor = await requests.claimReader(identityKey, next.request) + await replacement.sourceClosed(owner!) + expect(await db('snapshot_archive_owners').first()).toMatchObject(successor.owner!) + await replacement.markReaderCancellation(identityKey, next.request) + await recover() + expect(Number((await db('snapshot_archive_capacity').first()).archives)).toBe(0) + } finally { + stop.resolve() + finish.resolve() + await work.catch(() => undefined) + await source.destroy() + expiredAt = undefined + } + } + ) + ) + } finally { + clock.mockRestore() + await Promise.all([db.destroy(), peer.destroy()]) + await rm(directory, { recursive: true, force: true }) + } +}) + test('generated remote cancellation schedules retain source quota until exact cleanup acknowledgement', async () => { const directory = await mkdtemp(join(tmpdir(), 'snapshot-owner-property-')) const open = () => diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs index cea95306b..466b75f38 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs @@ -159,7 +159,11 @@ async function main() { if (process.argv[2] === 'child') { assert.equal(typeof process.send, 'function', 'Child execution requires its fixture parent') await child(process.cwd(), process.argv[3]) - } else await parent() + } else { + await parent() + const { qualifySQLiteGuardProcessLoss } = require('./snapshotArchiveGuardCrash.cjs') + console.log(JSON.stringify({ ownerProcessLoss: await qualifySQLiteGuardProcessLoss() })) + } } main().catch(error => { console.error(error) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveGuardChild.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveGuardChild.cjs new file mode 100644 index 000000000..909ab7cfc --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveGuardChild.cjs @@ -0,0 +1,22 @@ +// Owned synthetic process fixture; invoked only by snapshotArchiveGuardCrash.cjs. +const { knex } = require('knex') +const { readGuardedSnapshotArchive } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveGuard.js') +if (typeof process.send !== 'function') throw new Error('Owned fixture parent required') +process.once('message', ({ config, owner }) => { + const control = knex(config) + const source = knex(config) + void readGuardedSnapshotArchive(control, source, owner, async trx => { + const row = await trx('snapshot_guard_fixture').first() + process.send({ ready: true, value: row.value }) + await new Promise(resolve => process.once('message', resolve)) + }) + .catch(error => { + process.send({ ready: false, error: error.name }) + process.exitCode = 1 + }) + .finally(async () => { + await source.destroy() + await control.destroy() + process.disconnect() + }) +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveGuardCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveGuardCrash.cjs new file mode 100644 index 000000000..f0dc44bbd --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveGuardCrash.cjs @@ -0,0 +1,113 @@ +// Native process-loss qualification, using only caller-owned synthetic databases. +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { mkdtemp, rm } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { knex } = require('knex') +const { addSnapshotArchiveTables } = require('../../out/src/storage/schema/snapshotArchiveMigration.js') +const { addSnapshotArchiveRequestTable } = require('../../out/src/storage/schema/snapshotArchiveRequestMigration.js') +const { addSnapshotArchiveOwnerTable } = require('../../out/src/storage/schema/snapshotArchiveOwnerMigration.js') +const { addSnapshotArchiveGuardTable } = require('../../out/src/storage/schema/snapshotArchiveGuardMigration.js') +const { + KnexSnapshotArchiveRequestStore +} = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.js') +const { recoverSnapshotArchiveGuards } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveGuard.js') + +async function qualifyGuardProcessLoss(control, config) { + const requests = new KnexSnapshotArchiveRequestStore(control, true, true) + const identity = '02' + 'ee'.repeat(32) + const offered = await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }) + const { owner } = await requests.claimReader(identity, offered.request) + await control.schema.createTable('snapshot_guard_fixture', table => { + table.integer('id').primary() + table.string('value') + }) + await control('snapshot_guard_fixture').insert({ id: 1, value: 'before' }) + const child = spawn(process.execPath, [join(__dirname, 'snapshotArchiveGuardChild.cjs')], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + let stderr = '' + child.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-65536) + }) + const exited = new Promise((resolve, reject) => { + child.once('exit', (code, signal) => resolve({ code, signal })) + child.once('error', reject) + }) + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Owned guard child readiness deadline')), 15000) + child.once('message', message => { + clearTimeout(timer) + try { + assert.deepEqual(message, { ready: true, value: 'before' }) + resolve() + } catch (error) { + reject(error) + } + }) + void exited.then( + () => { + clearTimeout(timer) + reject(new Error(stderr || 'Owned guard child exited before readiness')) + }, + error => { + clearTimeout(timer) + reject(error) + } + ) + // Ephemeral fixture connection details travel through private IPC, never command arguments or output. + child.send({ config, owner }) + }) + await requests.markReaderCancellation(identity, offered.request) + await recoverSnapshotArchiveGuards(control, config) + await requests.reap() + assert.equal((await control('snapshot_archive_owners')).length, 1) + assert.equal(Number((await control('snapshot_archive_capacity').first()).archives), 1) + await control('snapshot_guard_fixture').update({ value: 'survives owner loss' }) + child.kill('SIGTERM') + const result = await exited + assert.equal(result.signal, 'SIGTERM') + await recoverSnapshotArchiveGuards(control, config) + await requests.reap() + assert.equal((await control('snapshot_archive_owners')).length, 0) + assert.equal(Number((await control('snapshot_archive_capacity').first()).archives), 0) + assert.equal((await control('snapshot_guard_fixture').first()).value, 'survives owner loss') + await assert.rejects(requests.claimReader(identity, offered.request), /unavailable/) + return { + actualSignal: result.signal, + busyBeforeLoss: true, + foregroundWrite: true, + exactOwnerRecovered: true, + staleRequestRefused: true + } + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM') + await exited + await control.schema.dropTable('snapshot_guard_fixture') + } +} + +async function qualifySQLiteGuardProcessLoss() { + const directory = await mkdtemp(join(tmpdir(), 'ts569-guard-process-')) + const config = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + } + const control = knex(config) + try { + await control.raw('PRAGMA journal_mode = WAL') + await addSnapshotArchiveTables(control) + await addSnapshotArchiveRequestTable(control) + await addSnapshotArchiveOwnerTable(control) + await addSnapshotArchiveGuardTable(control) + return await qualifyGuardProcessLoss(control, config) + } finally { + await control.destroy() + await rm(directory, { recursive: true, force: true }) + } +} +module.exports = { qualifyGuardProcessLoss, qualifySQLiteGuardProcessLoss } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index ee78910a3..3329a1e62 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -150,7 +150,7 @@ async function captureFixture() { assert.equal(manifest.pages, 14) assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') assert.equal(manifest.binding.user.activeStorage, 'historical selection') - assert.equal(manifest.binding.sourceSchema, '2026-10-01-001 add snapshot archive source owners') + assert.equal(manifest.binding.sourceSchema, '2026-10-01-002 add snapshot archive source guards') const store = new KnexSnapshotArchiveStore(writer.knex) const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) @@ -169,6 +169,12 @@ async function captureFixture() { const requestLifecycle = await requestFixture(writer, reader) const remoteReader = await readerFixture(writer, reader) const ownerDrain = await ownerDrainFixture(writer, reader) + const { qualifyGuardProcessLoss } = require('./snapshotArchiveGuardCrash.cjs') + const ownerProcessLoss = await qualifyGuardProcessLoss(writer.knex, { + client: 'mysql2', + connection, + pool: { min: 0, max: 1 } + }) await writer.insertCommission({ created_at: date, updated_at: date, @@ -193,7 +199,8 @@ async function captureFixture() { crossProfileClosureRejected: true, requestLifecycle, remoteReader, - ownerDrain + ownerDrain, + ownerProcessLoss } } finally { KnexSnapshotArchiveStore.prototype.append = originalAppend @@ -229,11 +236,11 @@ async function ownerDrainFixture(writer, reader) { assert.ok(source) const pool = writer.snapshotSyncSource assert.ok(pool) - const destroy = pool.destroy.bind(pool) - pool.destroy = async () => { + const destroy = pool.knex.client.destroyRawConnection.bind(pool.knex.client) + pool.knex.client.destroyRawConnection = async connection => { destroying.resolve() await allowDestroy.promise - await destroy() + await destroy(connection) } return { ...source, @@ -431,7 +438,7 @@ async function requestFixture(writer, reader) { sourceStorageIdentityKey: 'native-source', digest: ready.digest }) - assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-001 add snapshot archive source owners') + assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-002 add snapshot archive source guards') const page = await replacement.read(identity, ready.archiveId, 8) const decoded = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[8])) assert.equal(decoded.rows[0].label, 'replacement') diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 2a1dd83be..e36893c1d 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -193,3 +193,17 @@ but deliberately does not reclaim unproved process loss: backend-bound recovery remains required. The client now validates exact pending-cleanup receipts and uses fixed, bounded cancellation polling while awaiting I/O settlement. The server reader capability stays off and S3 remains open. + +The backend-guard checkpoint adds eight persistent slot bindings and a versioned +owner migration. New service sources bind local SQLite WAL file identities or +the actual MySQL server/database before their guarded read; old unguarded owners +remain reserved. Recovery takes the same backend guard, fences the exact expired +or terminal claim, physically closes its proof connection and only then releases +ownership. SQLite keeps its guard transaction open through native destruction; +MySQL waits for the socket close event. Focused and generated tests cover delayed +acquisition/close, immutable views, foreground writes, stale acknowledgements, +slot reuse and changed identities. Built-artifact SQLite/MySQL fixtures terminate +an owned synthetic process and recover its claim. This advances S3 orphan +recovery but does not qualify distributed filesystems, PXC, global physical-pool +limits, remote destinations or complete lifecycle/performance behavior. Reader +advertisement remains disabled and the full program remains incomplete. From 1199e332626e89170d227939796f1513141258bf Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 30 Sep 2026 23:10:38 -0700 Subject: [PATCH 065/127] ci(wallet): require native snapshot process-loss recovery proof --- .github/workflows/ci.yml | 4 ++++ scripts/ci-orchestration.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9153946a7..24b0d8827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -988,6 +988,10 @@ jobs: name: build-outputs path: .ci-artifacts - run: tar --extract --gzip --file .ci-artifacts/build-outputs.tar.gz + - name: Verify native snapshot crash recovery + if: matrix.id == 'shard-1' + working-directory: packages/wallet/wallet-toolbox + run: node test/storage/snapshotArchiveCrash.cjs - name: Generate wallet-toolbox coverage shard env: WALLET_SHARD: ${{ matrix.shard }} diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 47485f041..629f0130a 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -375,6 +375,32 @@ test('every HTTP latency scenario retains its own required coverage execution', assert.doesNotMatch(wallet, /continue-on-error|passWithNoTests/) }) +test('native snapshot process-loss proof uses the same-head build in exactly one required wallet shard', async () => { + const { parse } = await import('yaml') + const wallet = parse(readFileSync(CI_PATH, 'utf8')).jobs['coverage-wallet'] + const fixtures = wallet.steps.filter( + step => step.run === 'node test/storage/snapshotArchiveCrash.cjs' + ) + assert.equal(fixtures.length, 1) + const fixture = fixtures[0] + assert.equal(fixture.if, "matrix.id == 'shard-1'") + assert.equal(fixture['working-directory'], 'packages/wallet/wallet-toolbox') + assert.equal(fixture['continue-on-error'], undefined) + assert.equal(wallet['continue-on-error'], undefined) + assert.equal(wallet.strategy.matrix.include.filter(entry => entry.id === 'shard-1').length, 1) + const restored = wallet.steps.findIndex( + step => step.run === 'tar --extract --gzip --file .ci-artifacts/build-outputs.tar.gz' + ) + const proof = wallet.steps.indexOf(fixture) + const coverage = wallet.steps.findIndex( + step => step.name === 'Generate wallet-toolbox coverage shard' + ) + assert.ok(restored >= 0 && restored < proof && proof < coverage) + assert.equal(wallet.needs, 'prepare') + assert.equal(wallet['timeout-minutes'], 40) + assert.deepEqual(wallet.permissions, { contents: 'read' }) +}) + test('the mutation quality job accepts skipped execution only for explicitly empty scope', () => { const job = workflowJobBlocks(readFileSync(CI_PATH, 'utf8')).find( candidate => candidate.name === 'mutation-quality' From 38eacd7c0899d6ab42bf1daf0fe0792765abee01 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 00:34:08 -0700 Subject: [PATCH 066/127] fix(wallet): retain source fences after unproved snapshot cleanup --- docs/guides/wallet-sync-reliability.md | 6 + docs/reference/package-api-migrations.md | 74 +++--- governance/mutation-testing/targets.mjs | 1 + governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 3 + packages/wallet/wallet-toolbox/README.md | 3 + .../wallet-toolbox/src/storage/StorageKnex.ts | 14 +- .../ConcurrentSnapshotArchiveSource.test.ts | 199 ++++++++++++++- .../KnexSnapshotArchiveRequestStore.test.ts | 111 +++++++++ .../KnexSnapshotArchiveService.test.ts | 235 ++++++++++++++++++ .../archive/SnapshotArchiveGuard.test.ts | 86 +++++++ .../SnapshotArchiveGuardBackend.test.ts | 100 ++++++-- specs/wallet/sync-portability-program.md | 8 + 13 files changed, 773 insertions(+), 71 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 44d01a4fa..f90e84a92 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -762,6 +762,12 @@ pending. A still-unbound claim can be fenced under the capacity lock before a la binder enters. Independent backend and cleanup errors remain observable. Provider and service shutdown await an already-started recovery flight. +An unproved native close keeps the provider's source slot fenced even after its +read promise settles. Further snapshot sources and recovery on that provider +refuse, and destruction preserves the cleanup error. An ordinary read failure +still permits a later source after physical cleanup succeeds. Keep the failed +owner reserved until backend recovery independently proves closure. + Run migrations before capture, keep candidate binaries uniform, and drain all captures before downgrade. Guard files are persistent coordination state: do not unlink, replace or recreate a bound file to clear a reservation. Missing files, diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 32f61f9d1..0739f3726 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index defd5900c..9776e45b3 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -508,6 +508,7 @@ export function buildMutationTargets(repositoryRoot) { '/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts', '/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts', '/src/storage/snapshot/archive/SnapshotArchiveGuard*.test.ts', + '/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts', '/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts' ], { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 3fe1516c4..040a19d63 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 493a9eef5..5d575c7c5 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -15,6 +15,9 @@ attention to changes that materially alter behavior or extend functionality. exact-owner recovery only after physical connection closure. Preserve old unguarded reservations and refuse changed backend identities. Reader advertisement stays disabled pending complete qualification. + Keep the provider's source slot fenced after unproved native cleanup, including + an already-settled read promise; preserve retry after an ordinary read failure + whose physical cleanup succeeds. - Add the unadvertised remote row-reader foundation: immutable server-issued offers, exact-request retry, fixed leases, verified packed rows and durable diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index b0455275b..cb914a213 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -77,6 +77,9 @@ pending-cleanup polling, preserving independent failures. The additive guard migration now permits exact-owner recovery after physical source closure, using stable local SQLite WAL guard files or a same-server MySQL connection lock. Older unguarded owners remain reserved, and changed backend identities fail closed. +An unproved native close also fences further snapshot sources on that provider, +even after its read promise settles. Ordinary read failures remain retryable +after physical cleanup succeeds. Keep the guard files and bindings intact and drain captures before downgrade. The server reader capability remains unadvertised pending complete qualification. The complete sync/streaming/restore program remains in progress on #569. diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 433dd09ce..f6f048390 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -139,7 +139,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide private snapshotSyncOpening?: Promise private snapshotSyncBusy = false private snapshotArchiveRecovery?: Promise - private guardedSnapshotReadFailure?: { error: unknown } + private guardedSnapshotFailure?: { error: unknown } private retainedReadSnapshot?: RetainedReadSnapshotLifetime private retainedReadSnapshotsStopped = false readonly preparedBeefPolicy: PreparedBeefPolicy @@ -360,7 +360,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide await reader.makeAvailable() await readGuardedSnapshotArchive(this.knex, reader.knex, claim, read) } catch (error) { - if (!(error instanceof SnapshotArchiveSourceCleanupError)) reader.guardedSnapshotReadFailure = { error } + reader.guardedSnapshotFailure = { error } throw error } }) @@ -1986,9 +1986,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide try { await this.stopRetainedReadSnapshots() } catch (error) { - // The retained API preserves its read failure. That same failure does - // not mean the private pool's subsequent physical destruction failed. - if (this.guardedSnapshotReadFailure === undefined || this.guardedSnapshotReadFailure.error !== error) throw error + // Preserve an ordinary read failure for its read consumer. A typed + // cleanup failure remains observable after the pool destruction below. + if (this.guardedSnapshotFailure === undefined || this.guardedSnapshotFailure.error !== error) throw error } finally { await this.stopPreparedBeefTasks() this.knex.off('query', this.onQuery) @@ -1998,6 +1998,10 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide this.querySpans.clear() await this.knex?.destroy() } + // The lifetime may have settled and cleared its slot before destroy runs. + // A settled pool alone cannot prove that a failed native close succeeded. + const failure = this.guardedSnapshotFailure?.error + if (failure instanceof SnapshotArchiveSourceCleanupError) throw failure } override async migrate(storageName: string, storageIdentityKey: string): Promise { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts index 48dfd6b5b..652846821 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -10,6 +10,8 @@ import { KnexSnapshotArchiveRequestStore } from './archive/KnexSnapshotArchiveRe import { snapshotArchiveRequestId } from './archive/SnapshotArchiveRequest' import { captureSnapshotArchiveSource } from './archive/captureSnapshotArchiveSource' import { verifySnapshotArchiveDirectory } from './archive/SnapshotArchiveDirectory' +import * as ArchiveGuard from './archive/SnapshotArchiveGuard' +import { SnapshotArchiveSourceCleanupError } from './archive/KnexSnapshotArchiveSource' const identity = '02' + '11'.repeat(32) const stores: StorageKnex[] = [] @@ -54,7 +56,12 @@ test('a ready request waits for owned reader destruction while foreground storag expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() const requests = new KnexSnapshotArchiveRequestStore(storage.knex) const archives = new KnexSnapshotArchiveStore(storage.knex) - const fields = { version: 1 as const, nonce: 'b'.repeat(64), notAfter: Date.now() + 300000, maxBytes: 32768 } + const fields = { + version: 1 as const, + nonce: 'b'.repeat(64), + notAfter: Date.now() + 300000, + maxBytes: 32768 + } const input = { ...fields, requestId: snapshotArchiveRequestId(fields) } const admitted = await requests.claim(identity, input) const source = (await storage.openSnapshotArchiveSource(identity))! @@ -148,10 +155,198 @@ test('cancellation after source acquisition closes the owned pool before any arc test('unsupported in-memory SQLite does not construct a dedicated archive reader', async () => { const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), - knex: knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + knex: knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) }) stores.push(storage) expect(await storage.supportsSnapshotArchiveSource()).toBe(false) expect(await storage.openSnapshotArchiveSource(identity)).toBeUndefined() expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() }) + +test('one admitted recovery flight remains drained by destruction while foreground reads continue', async () => { + const storage = await fixture() + const entered = gate() + const finish = gate() + const recover = ArchiveGuard.recoverSnapshotArchiveGuards + const recovery = jest.spyOn(ArchiveGuard, 'recoverSnapshotArchiveGuards').mockImplementation(async (...args) => { + entered.resolve() + await finish.promise + await recover(...args) + }) + const destroy = jest.spyOn(storage.knex.client, 'destroy') + const first = storage.recoverSnapshotArchiveSources() + const second = storage.recoverSnapshotArchiveSources() + expect(second).toBe(first) + let destruction: Promise | undefined + try { + await entered.promise + expect((await storage.findUsers({ partial: { identityKey: identity } }))[0].identityKey).toBe(identity) + destruction = storage.destroy() + await expect(storage.recoverSnapshotArchiveSources()).rejects.toThrow('after destruction') + expect(destroy).not.toHaveBeenCalled() + expect(recovery).toHaveBeenCalledTimes(1) + finish.resolve() + await Promise.all([first, destruction]) + expect(destroy).toHaveBeenCalledTimes(1) + await storage.awaitSnapshotArchiveRecovery() + } finally { + finish.resolve() + await Promise.allSettled([first, destruction]) + } +}) + +test('failed recovery preserves its error and releases the flight for a later retry', async () => { + const storage = await fixture() + const failure = new Error('synthetic recovery enumeration failure') + const recovery = jest.spyOn(ArchiveGuard, 'recoverSnapshotArchiveGuards').mockRejectedValueOnce(failure) + await expect(storage.recoverSnapshotArchiveSources()).rejects.toBe(failure) + await storage.recoverSnapshotArchiveSources() + expect(recovery).toHaveBeenCalledTimes(2) + await storage.awaitSnapshotArchiveRecovery() + expect(await storage.supportsSnapshotArchiveSource()).toBe(true) +}) + +test('destruction drains a failing admitted recovery and still destroys the foreground pool', async () => { + const storage = await fixture() + const entered = gate() + const finish = gate() + const failure = new Error('synthetic admitted recovery failure') + jest.spyOn(ArchiveGuard, 'recoverSnapshotArchiveGuards').mockImplementation(async () => { + entered.resolve() + await finish.promise + throw failure + }) + const destroy = jest.spyOn(storage.knex.client, 'destroy') + const recovery = storage.recoverSnapshotArchiveSources() + void recovery.catch(() => undefined) + let destruction: Promise | undefined + try { + await entered.promise + destruction = storage.destroy() + void destruction.catch(() => undefined) + expect(destroy).not.toHaveBeenCalled() + finish.resolve() + await expect(recovery).rejects.toBe(failure) + await expect(destruction).rejects.toBe(failure) + expect(destroy).toHaveBeenCalledTimes(1) + await expect(storage.recoverSnapshotArchiveSources()).rejects.toThrow('after destruction') + } finally { + finish.resolve() + await Promise.allSettled([recovery, destruction]) + } +}) + +test('unsupported in-memory recovery performs no guard work and idle drainage is safe', async () => { + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + }) + stores.push(storage) + const recovery = jest.spyOn(ArchiveGuard, 'recoverSnapshotArchiveGuards') + await storage.awaitSnapshotArchiveRecovery() + await storage.recoverSnapshotArchiveSources() + expect(recovery).not.toHaveBeenCalled() +}) + +test('the static MySQL source probe does not construct or acquire a private connection', async () => { + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'mysql2', + connection: { + host: '127.0.0.1', + port: 1, + database: 'synthetic_unconnected_probe' + }, + pool: { min: 0, max: 1 } + }) + }) + stores.push(storage) + const acquire = jest.spyOn(storage.knex.client, 'acquireConnection').mockRejectedValue(new Error('Unexpected I/O')) + expect(await storage.supportsSnapshotArchiveSource()).toBe(true) + expect(acquire).not.toHaveBeenCalled() + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() +}) + +test.each(['opening', 'closing'] as const)( + 'unproved guarded source cleanup during %s preserves ownership and permanently fences its provider', + async phase => { + const storage = await fixture() + const requests = new KnexSnapshotArchiveRequestStore(storage.knex, true, true) + const offered = await requests.offer(identity, { + lifetimeMs: 300000, + maxBytes: 32768 + }) + const { owner } = await requests.claimReader(identity, offered.request) + const failure = new SnapshotArchiveSourceCleanupError(new Error('synthetic native cleanup outcome')) + const read = ArchiveGuard.readGuardedSnapshotArchive + let native: { open: boolean; close: () => void } | undefined + if (phase === 'opening') jest.spyOn(ArchiveGuard, 'readGuardedSnapshotArchive').mockRejectedValueOnce(failure) + else { + jest.spyOn(ArchiveGuard, 'readGuardedSnapshotArchive').mockImplementationOnce(async (...args) => { + jest.spyOn(args[1].client, 'destroyRawConnection').mockImplementation(async connection => { + native = connection + }) + return await read(...args) + }) + } + let received: unknown + try { + try { + const source = (await storage.openSnapshotArchiveSource(identity, {}, owner))! + await source.close() + } catch (error) { + received = error + } + expect(received).toBeInstanceOf(SnapshotArchiveSourceCleanupError) + if (phase === 'opening') expect(received).toBe(failure) + else expect(native?.open).toBe(true) + expect(await storage.knex('snapshot_archive_owners')).toHaveLength(1) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeDefined() + expect(await storage.supportsSnapshotArchiveSource()).toBe(false) + await expect(storage.openSnapshotArchiveSource(identity, {}, owner)).rejects.toThrow() + await expect(storage.getSnapshotSync()!.openSource(identity)).rejects.toThrow('destruction begins') + await expect(storage.recoverSnapshotArchiveSources()).rejects.toThrow('after destruction') + await expect(storage.destroy()).rejects.toBe(received) + } finally { + jest.restoreAllMocks() + // Only the synthetic fixture may prove and close this intentionally retained native connection. + if (native?.open) native.close() + stores.splice(stores.indexOf(storage), 1) + await storage.destroy().catch(error => { + expect(error).toBe(received) + }) + } + } +) + +test.each([new Error('synthetic guarded read failure'), undefined])( + 'an ordinary guarded read failure %p preserves its error but releases a physically cleaned reader', + async failure => { + const storage = await fixture() + const requests = new KnexSnapshotArchiveRequestStore(storage.knex, true, true) + const offered = await requests.offer(identity, { + lifetimeMs: 300000, + maxBytes: 32768 + }) + const { owner } = await requests.claimReader(identity, offered.request) + jest.spyOn(ArchiveGuard, 'readGuardedSnapshotArchive').mockRejectedValueOnce(failure) + await expect(storage.openSnapshotArchiveSource(identity, {}, owner)).rejects.toBe(failure) + expect(await storage.supportsSnapshotArchiveSource()).toBe(true) + expect(Reflect.get(storage, 'snapshotSyncSource')).toBeUndefined() + const source = (await storage.openSnapshotArchiveSource(identity, {}, owner))! + expect(source.user.identityKey).toBe(identity) + await source.close() + await requests.sourceClosed(owner!) + expect(await storage.knex('snapshot_archive_owners')).toHaveLength(0) + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts index 2843f2121..1ddccca50 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRequestStore.test.ts @@ -795,3 +795,114 @@ test('source-owner schema creation and removal are idempotent without discarding await removeSnapshotArchiveOwnerTable(db) expect(await db.schema.hasTable('snapshot_archive_owners')).toBe(false) }) + +test('a ready request with no bound archive refuses its receipt without releasing capacity', async () => { + const { db, requests } = await fixture() + const input = request() + await requests.claim(identity, input) + await db('snapshot_archive_requests').update({ state: 'ready' }) + await expect(requests.status(identity, input.requestId)).rejects.toThrow('Snapshot archive request is unavailable') + expect((await db('snapshot_archive_capacity').first()).archives).toBe(1) + expect((await db('snapshot_archive_requests').first()).released).toBe(0) +}) + +test.each(['claim', 'cancel', 'reader-cancel'] as const)( + '%s rejects an altered persisted immutable request tuple without changing its reservation', + async operation => { + const { db, requests } = await fixture() + const input = + operation === 'reader-cancel' + ? (await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))!.request + : request() + if (operation === 'reader-cancel') await requests.claimReader(identity, input) + else await requests.claim(identity, input) + await db('snapshot_archive_requests').update({ requestJson: JSON.stringify({ ...input, maxBytes: 32769 }) }) + const action = + operation === 'claim' + ? requests.claim(identity, input) + : operation === 'cancel' + ? requests.markCancellation(identity, input) + : requests.markReaderCancellation(identity, input) + await expect(action).rejects.toThrow('Snapshot archive request is unavailable') + expect((await db('snapshot_archive_requests').first()).state).toBe('claimed') + expect((await db('snapshot_archive_capacity').first()).archives).toBe(1) + } +) + +test.each(['request-id', 'deadline'] as const)( + 'reader collection compares a valid decoded tuple against its persisted %s and rolls back release', + async field => { + const { db, requests } = await fixture() + const issued = (await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + await requests.claimReader(identity, issued.request) + const fields = { ...issued.request, nonce: 'c'.repeat(64) } + const different = { ...fields, requestId: snapshotArchiveReaderRequestId(fields) } + await db('snapshot_archive_requests').update( + field === 'request-id' ? { requestJson: JSON.stringify(different) } : { expiresAt: issued.request.notAfter + 1 } + ) + await expect(requests.close(identity, issued.request.requestId)).rejects.toThrow( + 'Snapshot archive request is unavailable' + ) + expect(await db('snapshot_archive_requests').first()).toMatchObject({ state: 'closed', released: 0 }) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 1, reservedBytes: 32768 }) + await db('snapshot_archive_requests').update({ + requestJson: JSON.stringify(issued.request), + expiresAt: issued.request.notAfter + }) + await requests.close(identity, issued.request.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect((await db('snapshot_archive_capacity').first()).archives).toBe(0) + } +) + +test.each(['null', '0', '[]', '{}'])( + 'legacy terminal metadata %s is retained rather than collected as a version-two reader', + async requestJson => { + const { db, requests } = await fixture() + const input = request() + await requests.claim(identity, input) + await db('snapshot_archive_requests').update({ requestJson }) + await requests.close(identity, input.requestId) + expect(await db('snapshot_archive_requests').first()).toMatchObject({ state: 'closed', released: 1, requestJson }) + expect((await db('snapshot_archive_capacity').first()).archives).toBe(0) + } +) + +test('independent reader closers tolerate receipt collection between archive and request cleanup', async () => { + const { db, requests, second, archives } = await fixture() + const issued = (await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! + const { owner } = await requests.claimReader(identity, issued.request) + const writer = await requests.begin(owner!, binding) + await append(archives, writer) + await requests.seal(owner!, writer) + const close = KnexSnapshotArchiveStore.prototype.close + jest.spyOn(KnexSnapshotArchiveStore.prototype, 'close').mockImplementationOnce(async function ( + this: KnexSnapshotArchiveStore, + ...args + ) { + await close.apply(this, args) + await second.close(identity, issued.request.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + }) + await requests.close(identity, issued.request.requestId) + expect(await db('snapshot_archive_requests')).toHaveLength(0) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('reaping exposes an independent database failure and permits a later successful cleanup', async () => { + const { db, requests } = await fixture() + const input = request() + await requests.claim(identity, input) + await requests.markCancellation(identity, input) + await db.raw( + "CREATE TRIGGER fail_reap_release BEFORE UPDATE OF released ON snapshot_archive_requests WHEN NEW.released = 1 BEGIN SELECT RAISE(ABORT, 'synthetic reaper failure'); END" + ) + await expect(requests.reap()).rejects.toThrow('synthetic reaper failure') + expect((await db('snapshot_archive_requests').first()).released).toBe(0) + expect((await db('snapshot_archive_capacity').first()).archives).toBe(1) + await db.raw('DROP TRIGGER fail_reap_release') + await requests.reap() + expect((await db('snapshot_archive_requests').first()).released).toBe(1) + expect((await db('snapshot_archive_capacity').first()).archives).toBe(0) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts index b4eef3801..89db38ce8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveService.test.ts @@ -664,3 +664,238 @@ test('cancellation through another controller retains quota until the capturing await capture.catch(() => undefined) } }) + +test('admission envelopes distinguish acceptance, transient busy capacity and an active reader offer', async () => { + const { storage, controller } = await fixture() + const offered = (await controller.offerReader(other, { lifetimeMs: 300000, maxBytes: 32768 }))! + const input = request() + const alternative = request('c'.repeat(64)) + const entered = gate() + const resume = gate() + const original = storage.openSnapshotArchiveSource.bind(storage) + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (...args) => { + entered.resolve() + await resume.promise + return await original(...args) + }) + const capture = controller.create(identity, input) + void capture.catch(() => undefined) + try { + expect(await controller.admitRequest(identity, input)).toEqual({ + version: 1, + outcome: 'accepted', + receipt: { version: 1, requestId: input.requestId, state: 'building', expiresAt: input.notAfter } + }) + await entered.promise + expect(await controller.offerReader(other, { lifetimeMs: 300000, maxBytes: 32768 })).toBeUndefined() + expect(await controller.admitRequest(other, alternative)).toEqual({ + version: 1, + outcome: 'resource-limited', + requestId: alternative.requestId, + expiresAt: alternative.notAfter + }) + expect(await controller.admitReader(other, offered.request)).toEqual({ + version: 1, + outcome: 'resource-limited', + requestId: offered.request.requestId, + expiresAt: offered.request.notAfter + }) + resume.resolve() + expect((await capture).state).toBe('ready') + await controller.cancel(identity, input.requestId) + await controller.cancelReader(other, offered.request) + } finally { + resume.resolve() + await capture.catch(() => undefined) + } +}) + +test('admission never converts an independent recovery error or invalid input into capacity refusal', async () => { + const { storage, controller } = await fixture() + const failure = new Error('synthetic independent recovery failure') + jest.spyOn(storage, 'recoverSnapshotArchiveSources').mockRejectedValueOnce(failure) + await expect(controller.admitRequest(identity, request())).rejects.toBe(failure) + await expect(controller.admitRequest(identity, { ...request(), maxBytes: 0 })).rejects.toThrow('Invalid') + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) +}) + +test.each([true, false])('shutdown drains the result of a delayed offer, available=%s', async available => { + const { storage, controller } = await fixture() + const entered = gate() + const resume = gate() + const original = KnexSnapshotArchiveRequestStore.prototype.offer + jest.spyOn(KnexSnapshotArchiveRequestStore.prototype, 'offer').mockImplementationOnce(async function ( + this: KnexSnapshotArchiveRequestStore, + ...args + ) { + const offer = available ? await original.apply(this, args) : undefined + entered.resolve() + await resume.promise + return offer + }) + const pending = controller.offerReader(identity, { lifetimeMs: 300000, maxBytes: 32768 }) + void pending.catch(() => undefined) + try { + await entered.promise + await controller.close() + resume.resolve() + await expect(pending).rejects.toThrow('service is closed') + expect(await storage.knex('snapshot_archive_requests')).toHaveLength(0) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) + } finally { + resume.resolve() + await pending.catch(() => undefined) + } +}) + +test.each(['creation', 'reader'] as const)( + 'exact %s cancellation drains its active capture without cancelling a different request', + async kind => { + const { storage, controller } = await fixture() + const offered = + kind === 'reader' ? (await controller.offerReader(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! : undefined + const alternative = + kind === 'reader' ? (await controller.offerReader(identity, { lifetimeMs: 300000, maxBytes: 32768 }))! : undefined + const input = offered?.request ?? request() + const otherInput = alternative?.request ?? request('c'.repeat(64)) + const reading = gate() + const resume = gate() + const aborted = gate() + let signal: AbortSignal | undefined + const original = storage.openSnapshotArchiveSource.bind(storage) + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options, owner) => { + signal = options?.signal + signal!.addEventListener('abort', aborted.resolve, { once: true }) + const source = (await original(key, options, owner))! + return { + ...source, + readPage: async (...args) => { + reading.resolve() + await resume.promise + return await source.readPage(...args) + } + } + }) + const cancel = (value: unknown) => + kind === 'reader' ? controller.cancelReader(identity, value) : controller.cancelRequest(identity, value) + let cancellation: Promise | undefined + try { + if (kind === 'reader') await controller.admitReader(identity, input) + else await controller.start(identity, input) + await reading.promise + await cancel(otherInput) + expect(signal?.aborted).toBe(false) + expect(await storage.knex('snapshot_archive_owners')).toHaveLength(1) + let settled = false + cancellation = cancel(input).finally(() => { + settled = true + }) + void cancellation.catch(() => undefined) + await aborted.promise + expect(settled).toBe(false) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + resume.resolve() + await cancellation + expect(await storage.knex('snapshot_archive_owners')).toHaveLength(0) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) + if (kind === 'reader') expect(await storage.knex('snapshot_archive_requests')).toHaveLength(0) + else expect((await controller.status(identity, input.requestId)).state).toBe('closed') + } finally { + resume.resolve() + await cancellation?.catch(() => undefined) + } + } +) + +test('idle service shutdown waits for an already admitted provider recovery without destroying foreground storage', async () => { + const { storage, controller } = await fixture() + const entered = gate() + const resume = gate() + const original = ArchiveGuard.recoverSnapshotArchiveGuards + jest.spyOn(ArchiveGuard, 'recoverSnapshotArchiveGuards').mockImplementationOnce(async (...args) => { + entered.resolve() + await resume.promise + await original(...args) + }) + const recovery = storage.recoverSnapshotArchiveSources() + void recovery.catch(() => undefined) + let closing: Promise | undefined + try { + await entered.promise + let settled = false + closing = controller.close().finally(() => { + settled = true + }) + void closing.catch(() => undefined) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + resume.resolve() + await Promise.all([recovery, closing]) + expect((await storage.findUsers({ partial: { identityKey: identity } }))[0].identityKey).toBe(identity) + } finally { + resume.resolve() + await Promise.allSettled([recovery, closing]) + } +}) + +test.each([1, 1234, 600000])('source lifetime respects exactly %s remaining database milliseconds', async remaining => { + const { storage, controller } = await fixture() + const input = request() + jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(input.notAfter - remaining) + const opening = jest.spyOn(storage, 'openSnapshotArchiveSource').mockResolvedValueOnce(undefined) + await expect(controller.create(identity, input)).rejects.toThrow('requires SQLite WAL or MySQL') + expect(opening).toHaveBeenCalledWith( + identity, + { + signal: expect.any(AbortSignal), + lifetimeMs: Math.min(300000, remaining) + }, + expect.objectContaining({ identityKey: identity, requestId: input.requestId }) + ) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(0) +}) + +test('cancelling an active capture reports its cleanup failure and retains the reservation', async () => { + const { storage, controller } = await fixture() + const input = request() + const reading = gate() + const resume = gate() + const aborted = gate() + const failure = new Error('synthetic capture cleanup failure') + const original = storage.openSnapshotArchiveSource.bind(storage) + let source: Awaited> + jest.spyOn(storage, 'openSnapshotArchiveSource').mockImplementationOnce(async (key, options, owner) => { + options!.signal!.addEventListener('abort', aborted.resolve, { once: true }) + source = (await original(key, options, owner))! + return { + ...source, + readPage: async (...args) => { + reading.resolve() + await resume.promise + return await source!.readPage(...args) + }, + close: async () => { + throw failure + } + } + }) + const capture = controller.create(identity, input) + void capture.catch(() => undefined) + let cancellation: Promise | undefined + try { + await reading.promise + cancellation = controller.cancelRequest(identity, input) + void cancellation.catch(() => undefined) + await aborted.promise + resume.resolve() + await expect(capture).rejects.toBe(failure) + await expect(cancellation).rejects.toBe(failure) + expect((await storage.knex('snapshot_archive_capacity').first()).archives).toBe(1) + await expect(controller.close()).rejects.toBe(failure) + } finally { + resume.resolve() + await Promise.allSettled([capture, cancellation]) + await source?.close() + await closeRepairedSource(storage, input.requestId) + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.test.ts index f9da8807f..9fd2307f4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuard.test.ts @@ -16,6 +16,8 @@ import { readGuardedSnapshotArchive, recoverSnapshotArchiveGuards } from './Snap import { SnapshotArchiveSourceCleanupError } from './KnexSnapshotArchiveSource' import * as Backend from './SnapshotArchiveGuardBackend' import * as Registry from './SnapshotArchiveGuardRegistry' +import * as ArchiveSql from './SnapshotArchiveSql' +import { reserveSnapshotArchiveOwner } from './SnapshotArchiveOwner' const stores: Knex[] = [] const directories: string[] = [] @@ -119,6 +121,7 @@ test('recovery waits through physical connection close while foreground WAL writ await stop.promise expect((await trx('fixture_values').first()).value).toBe('before') }) + void work.catch(() => undefined) try { await opened.promise await f.requests.markCancellation(identity(), input) @@ -485,3 +488,86 @@ test('a concurrent exact acknowledgement makes an old recovery proof harmless', expect(await f.control('snapshot_archive_owners')).toHaveLength(0) expect(Number((await f.control('snapshot_archive_capacity').first()).archives)).toBe(0) }) + +test('omitting the guard flag preserves compatibility with the earlier owner schema', async () => { + const f = await fixture(false) + const owner = { identityKey: identity(), requestId: 'a'.repeat(64), claimToken: 'b'.repeat(64) } + await f.control.transaction(async trx => { + await ArchiveSql.lockSnapshotArchiveCapacity(trx) + await reserveSnapshotArchiveOwner(trx, owner) + }) + expect(await f.control('snapshot_archive_owners').first()).toEqual({ ...owner, slot: 0, archiveId: null }) + await addSnapshotArchiveGuardTable(f.control) + expect(await f.control('snapshot_archive_owners').first()).toMatchObject({ ...owner, guardVersion: 0 }) +}) + +test.each([-1, 8, Number.MAX_SAFE_INTEGER + 1])( + 'invalid owner slot %s is refused even if matching slot metadata exists', + async slot => { + const f = await fixture() + const { owner } = await f.requests.claim(identity(), request()) + await f.control('snapshot_archive_owners').where(owner!).update({ slot }) + await f.control('snapshot_archive_owner_slots').insert({ slot, bindingJson: null }) + await expect(Registry.readSnapshotArchiveOwnerGuard(f.control, owner!)).rejects.toThrow('unavailable') + } +) + +test.each([0.5, Number.NaN])('a driver returning noninteger owner slot %s is refused', async slot => { + const f = await fixture() + const { owner } = await f.requests.claim(identity(), request()) + f.control.client.config.postProcessResponse = (value: unknown) => + value !== null && typeof value === 'object' && Object.keys(value).length === 1 && 'slot' in value ? { slot } : value + await expect(Registry.readSnapshotArchiveOwnerGuard(f.control, owner!)).rejects.toThrow('unavailable') +}) + +test.each(['closed', 'failed', 'resource-limited'] as const)( + 'a %s owner is enumerated and fenced without rewriting its terminal outcome or acknowledging a live peer', + async state => { + const f = await fixture() + const { owner } = await f.requests.claim(identity(), request()) + const { owner: peer } = await f.requests.claim(identity(2), request(2)) + const context = await Registry.readSnapshotArchiveOwnerGuard(f.control, owner!) + await expect(Registry.assertSnapshotArchiveGuardOwner(f.control, { ...context, slot: 1 })).rejects.toThrow( + 'unavailable' + ) + await f.control('snapshot_archive_requests').where(owner!).update({ state }) + expect(await Registry.expiredSnapshotArchiveOwnerGuards(f.control)).toEqual([context]) + expect( + await f.control.transaction(trx => Registry.fenceSnapshotArchiveOwnerGuard(trx, { ...context, slot: 1 })) + ).toBe(false) + expect(await f.control.transaction(trx => Registry.fenceSnapshotArchiveOwnerGuard(trx, context))).toBe(true) + expect((await f.control('snapshot_archive_requests').where(owner!).first()).state).toBe(state) + expect(await f.control('snapshot_archive_owners')).toHaveLength(2) + expect(await f.control.transaction(trx => Registry.fenceSnapshotArchiveOwnerGuard(trx, context, true))).toBe(true) + expect(await f.control('snapshot_archive_owners')).toEqual([expect.objectContaining(peer!)]) + } +) + +test('the immutable deadline itself closes guard admission and permits expiry fencing', async () => { + const f = await fixture() + const input = request() + const { owner } = await f.requests.claim(identity(), input) + const context = await Registry.readSnapshotArchiveOwnerGuard(f.control, owner!) + jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(input.notAfter) + await expect(Registry.assertSnapshotArchiveGuardOwner(f.control, context)).rejects.toThrow('unavailable') + expect(await Registry.expiredSnapshotArchiveOwnerGuards(f.control)).toEqual([context]) + expect(await f.control.transaction(trx => Registry.fenceSnapshotArchiveOwnerGuard(trx, context))).toBe(true) + expect((await f.control('snapshot_archive_requests').where(owner!).first()).state).toBe('expired') + expect(await f.control('snapshot_archive_owners')).toHaveLength(1) +}) + +test.each(['g'.repeat(64), 'a'.repeat(63), 'a'.repeat(65), 'z' + 'a'.repeat(64), 'a'.repeat(64) + 'z'])( + 'an invalid initial guard marker %s cannot be accepted before the slot is bound', + async marker => { + const f = await fixture() + await Backend.prepareSnapshotArchiveGuardBackend(f.control, 0, null) + const metadata = knex({ ...f.config, connection: { filename: `${f.filename}.snapshot-owner-0.sqlite` } }) + try { + await metadata('snapshot_owner_guard').where({ id: 1 }).update({ marker }) + } finally { + await metadata.destroy() + } + await expect(Backend.prepareSnapshotArchiveGuardBackend(f.control, 0, null)).rejects.toThrow('identity changed') + expect((await f.control('snapshot_archive_owner_slots').where({ slot: 0 }).first()).bindingJson).toBeNull() + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.test.ts index c71ce9124..79411ea5a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardBackend.test.ts @@ -68,6 +68,8 @@ test.each([ undefined, { serverUuid: null, databaseName: 'fixture' }, { serverUuid: 'not-a-server', databaseName: 'fixture' }, + { serverUuid: 'a' + uuid, databaseName: 'fixture' }, + { serverUuid: uuid + 'a', databaseName: 'fixture' }, { serverUuid: uuid, databaseName: undefined }, { serverUuid: uuid, databaseName: '' }, { serverUuid: uuid, databaseName: 'x'.repeat(65) } @@ -82,9 +84,11 @@ test.each(['server', 'database'] as const)( async field => { const f = fixture() const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) - f.reply.mockReturnValue([ - [field === 'server' ? { ...identity, serverUuid: 'a'.repeat(36) } : { ...identity, databaseName: 'other' }] - ]) + f.reply.mockImplementation(sql => + sql.startsWith('SELECT @@') + ? [[field === 'server' ? { ...identity, serverUuid: 'a'.repeat(36) } : { ...identity, databaseName: 'other' }]] + : [[{ acquired: 1 }]] + ) const read = jest.fn() await expect(withSnapshotArchiveBackendGuard(f.k, binding, read)).rejects.toThrow('identity changed') expect(read).not.toHaveBeenCalled() @@ -126,23 +130,29 @@ test('a graceful quit and a destroyed socket still wait for physical close befor }).finally(() => { settled = true }) - await quitting - await new Promise(resolve => setImmediate(resolve)) - expect(settled).toBe(false) - expect(f.trx.rollback).not.toHaveBeenCalled() - expect(f.stream.listenerCount('close')).toBe(1) - expect(f.queries.slice(1).map(query => query.sql)).toEqual([ - 'SELECT @@server_uuid AS serverUuid, DATABASE() AS databaseName', - 'SELECT GET_LOCK(?, 0) AS acquired', - 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY' - ]) - expect(f.queries.slice(1).every(query => query.connection === f.connection)).toBe(true) - expect(f.queries[2].values).toEqual([(binding as Extract).lock]) - expect(f.transaction).toHaveBeenCalledWith({ connection: f.connection }) - f.close() - await expect(work).resolves.toBe(42) - expect(f.trx.rollback).toHaveBeenCalledTimes(1) - expect(f.stream.listenerCount('close')).toBe(0) + void work.catch(() => undefined) + try { + await quitting + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + expect(f.trx.rollback).not.toHaveBeenCalled() + expect(f.stream.listenerCount('close')).toBe(1) + expect(f.queries.slice(1).map(query => query.sql)).toEqual([ + 'SELECT @@server_uuid AS serverUuid, DATABASE() AS databaseName', + 'SELECT GET_LOCK(?, 0) AS acquired', + 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY' + ]) + expect(f.queries.slice(1).every(query => query.connection === f.connection)).toBe(true) + expect(f.queries[2].values).toEqual([(binding as Extract).lock]) + expect(f.transaction).toHaveBeenCalledWith({ connection: f.connection }) + f.close() + await expect(work).resolves.toBe(42) + expect(f.trx.rollback).toHaveBeenCalledTimes(1) + expect(f.stream.listenerCount('close')).toBe(0) + } finally { + if (!f.stream.closed) f.close() + await work.catch(() => undefined) + } }) test('a socket destroyed before cleanup still waits for its pending close event', async () => { @@ -159,11 +169,17 @@ test('a socket destroyed before cleanup still waits for its pending close event' f.stream.destroyed = true return 'read' }) - await started - expect(f.stream.listenerCount('close')).toBe(1) - expect(f.trx.rollback).not.toHaveBeenCalled() - f.close() - await expect(work).resolves.toBe('read') + void work.catch(() => undefined) + try { + await started + expect(f.stream.listenerCount('close')).toBe(1) + expect(f.trx.rollback).not.toHaveBeenCalled() + f.close() + await expect(work).resolves.toBe('read') + } finally { + if (!f.stream.closed) f.close() + await work.catch(() => undefined) + } }) test.each([new Error('read failed'), undefined])( @@ -225,3 +241,37 @@ test('unsupported backend refuses before acquiring a source', async () => { await expect(prepareSnapshotArchiveGuardBackend(f.k, 0, null)).rejects.toThrow('require better-sqlite3 WAL or MySQL') expect(f.client.acquireConnection).not.toHaveBeenCalled() }) + +test.each([1, 64])('MySQL accepts a valid database name of exactly %s characters', async length => { + const f = fixture() + const databaseName = 'd'.repeat(length) + f.reply.mockReturnValue([[{ serverUuid: uuid.toUpperCase(), databaseName }]]) + expect(await prepareSnapshotArchiveGuardBackend(f.k, 0, null)).toMatchObject({ + kind: 'mysql', + serverUuid: uuid.toUpperCase(), + database: databaseName + }) +}) + +test.each(['missing-stream', 'unproved-event'] as const)( + 'MySQL cleanup refuses %s without treating the read transaction as released', + async kind => { + const f = fixture() + const binding = await prepareSnapshotArchiveGuardBackend(f.k, 0, null) + if (kind === 'missing-stream') Reflect.deleteProperty(f.connection, 'stream') + else + f.destroy.mockImplementation(async () => { + f.stream.emit('close') + }) + try { + await expect(withSnapshotArchiveBackendGuard(f.k, binding, async () => 1)).rejects.toMatchObject({ + name: 'SnapshotArchiveSourceCleanupError', + cause: { message: 'Snapshot archive source connection did not close' } + }) + expect(f.trx.rollback).not.toHaveBeenCalled() + expect(f.stream.listenerCount('close')).toBe(0) + } finally { + f.close() + } + } +) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index e36893c1d..2ed675e72 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -207,3 +207,11 @@ an owned synthetic process and recover its claim. This advances S3 orphan recovery but does not qualify distributed filesystems, PXC, global physical-pool limits, remote destinations or complete lifecycle/performance behavior. Reader advertisement remains disabled and the full program remains incomplete. + +Provider lifecycle qualification also preserves an unproved cleanup failure +after the retained read promise settles. The provider keeps its source slot +fenced and destruction exposes that error; an ordinary read failure remains +retryable after proved physical cleanup. Regression tests cover both opening +and closing failures, shared recovery admission, shutdown drainage, immutable +request bindings and exact cancellation outcomes. These checks do not replace +the complete mutation campaign or exact-head hosted qualification. From 56143a0a0a5258c8ae25b2e26934e6d8e40403e9 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 01:36:36 -0700 Subject: [PATCH 067/127] ci(wallet): allow complete snapshot guard mutation qualification --- .github/workflows/ci.yml | 4 +++- .github/workflows/mutation-tests.yml | 4 +++- scripts/ci-orchestration.test.mjs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24b0d8827..6d18cda65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,7 +501,9 @@ jobs: runs-on: ubuntu-latest # The governed air-gap codec target currently instruments 352 mutants and # legitimately exceeds 20 minutes on a hosted runner. - timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader"]'), matrix.target) && 90 || 45 }} + # Complete snapshot source-guard service qualification took 58m35s locally, + # before hosted checkout/install. Preserve the 45-minute default elsewhere. + timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index ecc9b6668..8c09bbc81 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -103,7 +103,9 @@ jobs: needs: prepare runs-on: ubuntu-latest # Preserve the acknowledged wallet/overlays90-minute target union. - timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader"]'), matrix.target) && 90 || 45 }} + # Complete snapshot source-guard service qualification took 58m35s locally, + # before hosted checkout/install. Preserve the 45-minute default elsewhere. + timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 629f0130a..7e874cbd7 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -27,7 +27,7 @@ function workflowJobBlocks(workflow) { function assertWalletMutationTimeout(job, defaultMinutes) { const targets = - '["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader"]' + '["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]' const expected = ` timeout-minutes: \${{ contains(fromJSON('${targets}'), matrix.target) && 90 || ${defaultMinutes} }}` assert.equal(job.source.match(/^ timeout-minutes: .+$/m)?.[0], expected) } From 35140d2a189468f9c20c90b7f70fad07064dded6 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 02:28:28 -0700 Subject: [PATCH 068/127] test(wallet): observe cleanup failures in snapshot timing assertions --- .../archive/RemoteSnapshotCleanup.test.ts | 10 +++++++--- .../archive/RemoteSnapshotLease.test.ts | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotCleanup.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotCleanup.test.ts index 922e82cfc..36f7cf588 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotCleanup.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotCleanup.test.ts @@ -70,9 +70,13 @@ test('pending cleanup uses bounded exponential delay and one signal until a real if (calls.length < 6) throw new SnapshotArchiveCleanupPendingError() }) let completed = false - const closing = drainSnapshotArchiveRequest(cancel).then(() => { - completed = true - }) + const closing = drainSnapshotArchiveRequest(cancel) + void closing.then( + () => { + completed = true + }, + () => undefined + ) await jest.advanceTimersByTimeAsync(2499) expect(completed).toBe(false) expect(calls).toEqual([1000000, 1000100, 1000300, 1000700, 1001500]) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts index 55d3b3e2d..d76f77d7a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotLease.test.ts @@ -69,9 +69,12 @@ test('close fences synchronously, drains the client operation and cancels the ex await expect(lease.run(async () => 2)).rejects.toThrow('already has an operation') let closed = false const closing = lease.close() - void closing.then(() => { - closed = true - }) + void closing.then( + () => { + closed = true + }, + () => undefined + ) expect(lease.close()).toBe(closing) expect(lease.isOpen).toBe(false) expect(signal!.aborted).toBe(true) @@ -289,9 +292,13 @@ test('poll delay holds the operation until its exact duration without issuing re const { transport, rpc } = remoteReaderFixture([]) const lease = new RemoteSnapshotLease(transport, { lifetimeMs: 1000 }) let finished = false - const waiting = lease.wait(100).then(() => { - finished = true - }) + const waiting = lease.wait(100) + void waiting.then( + () => { + finished = true + }, + () => undefined + ) await jest.advanceTimersByTimeAsync(99) expect(finished).toBe(false) await jest.advanceTimersByTimeAsync(1) From 9de2cc2248e975c8eade05d570ef35cea7d33f59 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 04:04:56 -0700 Subject: [PATCH 069/127] Add restartable snapshot profile keys and native MySQL CI --- .github/workflows/ci.yml | 14 + docs/guides/wallet-sync-reliability.md | 61 +++- docs/reference/package-api-migrations.md | 74 ++-- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/README.md | 31 +- .../src/storage/schema/KnexMigrations.ts | 14 + .../schema/snapshotProfileIndexMigration.ts | 303 +++++++++++++++++ .../ConcurrentSnapshotArchiveSource.test.ts | 2 +- .../KnexWalletReadSnapshot.mysql.test.ts | 9 +- .../snapshot/KnexWalletReadSnapshot.test.ts | 64 ++-- .../snapshot/KnexWalletReadSnapshot.ts | 55 ++- .../RetainedReadSnapshot.property.test.ts | 81 +++++ ...SnapshotProfileIndexes.integration.test.ts | 206 ++++++++++++ .../SnapshotProfileIndexes.migration.test.ts | 85 +++++ .../SnapshotProfileIndexes.mysql.test.ts | 315 ++++++++++++++++++ .../snapshot/SnapshotProfileIndexes.test.ts | 273 +++++++++++++++ .../KnexSnapshotArchiveCapture.test.ts | 8 +- .../archive/KnexSnapshotArchiveClosure.ts | 4 +- .../archive/KnexSnapshotArchiveSource.ts | 16 +- .../archive/KnexSnapshotArchiveStore.test.ts | 2 +- .../archive/SnapshotArchiveHttp.test.ts | 2 +- .../test/storage/runSnapshotArchiveMysql.cjs | 158 ++++++--- .../test/storage/snapshotArchiveCrash.cjs | 2 + .../test/storage/snapshotArchiveDocker.cjs | 59 +++- .../storage/snapshotArchiveDocker.test.ts | 99 ++++++ .../test/storage/snapshotArchiveMysql.cjs | 33 +- .../snapshotArchiveMysqlLauncher.test.ts | 188 +++++++++++ .../storage/snapshotProfileIndexCrash.cjs | 238 +++++++++++++ .../storage/snapshotProfileIndexMysql.cjs | 152 +++++++++ scripts/ci-orchestration.test.mjs | 39 +++ specs/wallet/sync-portability-program.md | 11 + 31 files changed, 2430 insertions(+), 172 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.mysql.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysqlLauncher.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexCrash.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexMysql.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d18cda65..54de791a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -994,6 +994,20 @@ jobs: if: matrix.id == 'shard-1' working-directory: packages/wallet/wallet-toolbox run: node test/storage/snapshotArchiveCrash.cjs + - name: Provision pinned native snapshot MySQL fixture + if: matrix.id == 'shard-1' + working-directory: packages/wallet/wallet-toolbox + timeout-minutes: 5 + env: + TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1' + run: node test/storage/snapshotArchiveDocker.cjs provision-hosted-image + - name: Verify native snapshot MySQL recovery + if: matrix.id == 'shard-1' + working-directory: packages/wallet/wallet-toolbox + timeout-minutes: 5 + env: + TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1' + run: node test/storage/runSnapshotArchiveMysql.cjs - name: Generate wallet-toolbox coverage shard env: WALLET_SHARD: ${{ matrix.shard }} diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index f90e84a92..36451cea1 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -212,14 +212,59 @@ charge bounds stored page payload, not encoded wire size or measured process RSS; callers must release consumed pages. Large-value streaming remains required for records exceeding the maximum budget, and header/schema metadata is separate. -Traversal uses existing unique keys, including label/tag-first mapping keys and -field-name-first certificate keys, with each database's collation. This order is -not the canonical BRC-38 array order. No OFFSET, full-table count, new index or -persistence migration is introduced. Existing legacy sync checkpoints and query -plans are unchanged. SQLite query-plan tests verify range seeks for numeric and -composite keys. Identity/update-key indexing, commit-order incremental high-water -positions, remote handles, IDB retention and streaming remain part of the active -program. The local sync integration below consumes these pages. +Traversal preserves the original unique-key order, including label/tag-first +mapping keys and field-name-first certificate keys, with each database's +collation. This order differs from canonical BRC-38 array order. The auxiliary +profile index below adds bounded profile range selection for eight direct tables +without altering any standard-table index, legacy OFFSET order or cursor bytes. +Relationship/global-table query work, commit-order incremental high-water +positions, IndexedDB retention and large-value streaming remain required by the +active program. The local sync integration below consumes these pages. + +### Auxiliary profile indexes (unpublished candidate) + +Migration `2026-10-01-003 add snapshot profile key indexes` adds +`snapshot_profile_keys` and `snapshot_profile_index_progress`. The first table +holds `(table, user, row)` keys for transactions, outputs, certificates, labels, +baskets, tags, commissions and sync states. Source-table insert, key/profile +update and delete triggers maintain the keys in the writer's transaction, +including writes from older binaries and independent connections. The standard +tables and indexes remain intact. These auxiliary tables are not wallet archive +content and do not change BRC-38, legacy sync or snapshot cursor encodings. + +Use the normal provider migration entry point. This migration intentionally +uses `transaction: false`: MySQL DDL commits independently, and each bootstrap +batch commits its keys and progress together in a separate transaction on both +backends. A batch reads at most 256 source rows. SQLite takes its writer lock +before reading progress; MySQL locks the current progress and source rows until +commit. Triggers precede bootstrap, so independent profile moves, deletes and +inserts behind a committed cursor remain visible to resumed indexing. No +standard table is rebuilt and no wallet row is rewritten. + +An interruption can leave auxiliary tables, triggers or committed bootstrap +batches before the migration journal entry. Retain them and retry the migration; +matching objects and committed positions are reused. Mismatched definitions or +malformed progress refuse completion. An abruptly terminated migrator may also +leave Knex's migration lock claimed. Before recovering that lock, independently +verify that its migrator has stopped and exclude every other migrator; this +implementation does not automatically break a migration lock. Keep a verified +backup and use the existing migration recovery procedure. + +A retained reader chooses its mode from the exact migration journal entry and +complete progress in the same view as its header and pages. Missing journal +entries retain the previous traversal. A journaled but incomplete or mismatched +auxiliary schema refuses the new view. Existing views keep their original mode +across later migration or writer commits. Ordinary pages, archive pages and +archive closure checks share that choice; no private mode flag is added to +public metadata. + +Drain snapshot readers and exclude concurrent migrators before rollback. The +migration's down operation validates owned objects, stops insert/update producers +before deleting their observers, then removes only the auxiliary tables and its +journal entry. Standard wallet rows and indexes remain unchanged. Older binaries +may read and write the unchanged standard schema while the forward migration's +triggers remain installed; binary downgrade procedures still apply to the +candidate's other migrations. ## Durable local SQL sync and ordinary backup diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 0739f3726..1e7856357 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 040a19d63..d34372469 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index cb914a213..71ed10eb9 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -39,6 +39,18 @@ retain their documented caller-quiesced fallback. Recognized optional nullable JSON fields are omitted in a detached archive copy; array entries and meaningful falsy values are preserved. The helpers still materialize the full document/file, and IndexedDB writers wait during capture. +Run `pnpm test:snapshot-archive-crash` for native SQLite process recovery and +`pnpm test:snapshot-archive-mysql` for the disposable MySQL fixture from this +package. Local MySQL qualification uses Docker Desktop and requires the pinned +image already present. Required wallet CI shard 1 provisions that same immutable +image on its ephemeral Linux runner, then runs both native fixtures from the +same-head build before Jest. Hosted MySQL mode accepts only the local default +Unix socket. The fixture uses a unique ownership label, loopback port, bounded +memory/CPU/process count and temporary data volume; individual Docker calls, +readiness and the child proof have deadlines. Failure or cancellation drains +owned work and attempts exact-owner cleanup before reporting its outcome; +unproved cleanup fails qualification. Other wallet shards do not start MySQL. + SQL providers also expose `supportsRetainedReadSnapshot` / `openReadSnapshot` for a local view held across idle reads, with one view per provider, one read at a time, and bounded lifetime/cancellation. Await `closed`/`close()` for physical @@ -82,6 +94,11 @@ even after its read promise settles. Ordinary read failures remain retryable after physical cleanup succeeds. Keep the guard files and bindings intact and drain captures before downgrade. The server reader capability remains unadvertised pending complete qualification. +The auxiliary profile-key migration preserves standard-table indexes and legacy +OFFSET ordering while adding indexed snapshot selection for eight direct tables. +Its triggers track independent writers; bounded bootstrap batches survive restart, +and readers enable the index only from a complete migration in their retained +view. See the [migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-profile-indexes-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results @@ -107,14 +124,18 @@ Timing compares successive candidates, not a controlled comparison against upstr ### SQLite migration recovery -SQLite migration handling introduced in 2.13.2 runs migration DDL and the migration -journal update transactionally. Foreign-key enforcement is disabled before the +SQLite migration handling introduced in 2.13.2 runs transactional migration DDL +and the migration journal update together. The unpublished profile-index +migration is an explicit resumable exception: auxiliary keys and progress commit +in bounded batches before its final migration journal entry. Foreign-key enforcement is disabled before the migration transaction for table rebuilds and restored after success or failure. -Failed migrations can be retried after reopening the database without partial -schema objects from that attempt. MySQL's existing transaction configuration +Failed transactional migrations can be retried after reopening the database +without partial schema objects from that attempt. The resumable profile-index +migration instead retains its verified auxiliary objects and committed progress; +see its linked recovery contract before retrying an interrupted migrator. MySQL's existing transaction configuration is unchanged. -This prevents future partial migrations. It does not automatically repair a +Transactional migration handling prevents partial table rebuilds. It does not automatically repair a store already left with unjournaled schema objects by an older version. Preserve the database and verified backups and reconcile the exact schema and migration journal before recovery; do not delete journal rows or wallet data blindly. diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index a222973b3..fd25f5005 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,8 @@ +import { + addSnapshotProfileIndexes, + removeSnapshotProfileIndexes, + SNAPSHOT_PROFILE_INDEX_MIGRATION +} from './snapshotProfileIndexMigration' import { addSnapshotArchiveGuardTable, removeSnapshotArchiveGuardTable, @@ -32,6 +37,7 @@ import { LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS } from '../methods/managedChangePolicy' +export { SNAPSHOT_PROFILE_INDEX_MIGRATION } from './snapshotProfileIndexMigration' export { SNAPSHOT_ARCHIVE_OWNER_MIGRATION } from './snapshotArchiveOwnerMigration' export { SNAPSHOT_ARCHIVE_GUARD_MIGRATION } from './snapshotArchiveGuardMigration' export { SNAPSHOT_ARCHIVE_REQUEST_MIGRATION } from './snapshotArchiveRequestMigration' @@ -120,6 +126,14 @@ export class KnexMigrations implements MigrationSource { } } + // DDL may commit independently on MySQL. Bootstrap pages retain their own + // durable positions on both backends and resume before journal publication. + migrations[SNAPSHOT_PROFILE_INDEX_MIGRATION] = { + config: { transaction: false }, + up: addSnapshotProfileIndexes, + down: removeSnapshotProfileIndexes + } + migrations[SNAPSHOT_ARCHIVE_GUARD_MIGRATION] = { config: { transaction: true }, up: addSnapshotArchiveGuardTable, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts new file mode 100644 index 000000000..d33c663d2 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts @@ -0,0 +1,303 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' + +export const SNAPSHOT_PROFILE_INDEX_MIGRATION = '2026-10-01-003 add snapshot profile key indexes' + +export const snapshotProfileTables = [ + { table: 'transactions', key: 'transactionId' }, + { table: 'outputs', key: 'outputId' }, + { table: 'certificates', key: 'certificateId' }, + { table: 'tx_labels', key: 'txLabelId' }, + { table: 'output_baskets', key: 'basketId' }, + { table: 'output_tags', key: 'outputTagId' }, + { table: 'commissions', key: 'commissionId' }, + { table: 'sync_states', key: 'syncStateId' } +] as const + +const KEYS = 'snapshot_profile_keys' +const PROGRESS = 'snapshot_profile_index_progress' +const PAGE_ROWS = 256 + +function isMySQL(k: Knex): boolean { + return String(k.client.config.client).includes('mysql') +} + +function normalized(sql: string): string { + return sql + .replace(/\s+/g, ' ') + .replace(/ IF NOT EXISTS /g, ' ') + .trim() +} + +async function validateTable(k: Knex, table: string): Promise { + const keys = table === KEYS + const names = keys + ? ['snapshotTableId', 'snapshotUserId', 'snapshotRowId'] + : ['snapshotTableId', 'afterRowId', 'complete'] + const primary = keys ? names : ['snapshotTableId'] + let valid: boolean + if (isMySQL(k)) { + const [columns]: Array< + Array<{ name: string; type: string; nullable: string; defaultValue: unknown; extra: string }> + > = await k.raw( + 'SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION', + [table] + ) + const [indexes]: Array< + Array<{ name: string; columnName: string; nonUnique: number; direction: string; prefix: unknown }> + > = await k.raw( + 'SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX', + [table] + ) + const types = keys ? ['int', 'int unsigned', 'int unsigned'] : ['int', 'int unsigned', 'tinyint'] + valid = + Array.isArray(columns) && + columns.length === names.length && + columns.every( + (column, i) => + column.name === names[i] && + column.type.replace(/\(\d+\)/g, '') === types[i] && + column.nullable === 'NO' && + column.defaultValue === null && + column.extra === '' + ) && + Array.isArray(indexes) && + indexes.every(index => + index.name === 'PRIMARY' ? index.direction === 'A' && index.prefix === null : Number(index.nonUnique) === 1 + ) && + JSON.stringify(indexes.filter(index => index.name === 'PRIMARY').map(index => index.columnName)) === + JSON.stringify(primary) + } else { + const columns: Array<{ + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + hidden: number + }> = await k.raw('PRAGMA table_xinfo(??)', [table]) + const indexes: Array<{ name: string; unique: number; origin: string; partial: number }> = await k.raw( + 'PRAGMA index_list(??)', + [table] + ) + const expectedPk = keys ? [1, 2, 3] : [1, 0, 0] + const nullable = keys ? [1, 1, 1] : [0, 1, 1] + const types = keys ? ['integer', 'integer', 'integer'] : ['integer', 'integer', 'boolean'] + valid = + Array.isArray(columns) && + columns.length === names.length && + columns.every( + (column, i) => + column.name === names[i] && + column.type.toLowerCase() === types[i] && + column.notnull === nullable[i] && + column.dflt_value === null && + column.pk === expectedPk[i] && + column.hidden === 0 + ) && + Array.isArray(indexes) && + indexes.every(index => index.unique === 0 || (index.origin === 'pk' && index.partial === 0)) + const pk = indexes.find(index => index.origin === 'pk') + if (valid && keys) { + if (pk === undefined) valid = false + else { + const parts: Array<{ name: string; desc: number; coll: string; key: number }> = await k.raw( + 'PRAGMA index_xinfo(??)', + [pk.name] + ) + const indexed = parts.filter(part => part.key === 1) + valid = + indexed.length === names.length && + indexed.every((part, i) => part.name === names[i] && part.desc === 0 && part.coll === 'BINARY') + } + } + } + if (!valid) throw new WERR_INVALID_OPERATION('Snapshot profile table definition mismatch') +} + +async function installTrigger( + k: Knex, + table: string, + key: string, + tableId: number, + event: 'DELETE' | 'UPDATE' | 'INSERT', + create = true +): Promise { + const name = `snapshot_profile_${tableId}_${event.toLowerCase()}` + const remove = `DELETE FROM ${KEYS} WHERE snapshotTableId = ${tableId} AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.${key};` + const add = `INSERT INTO ${KEYS} (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (${tableId}, NEW.userId, NEW.${key});` + const mysql = isMySQL(k) + const changed = mysql + ? `NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.${key} <=> NEW.${key})` + : `OLD.userId IS NOT NEW.userId OR OLD.${key} IS NOT NEW.${key}` + const statements = event === 'DELETE' ? remove : event === 'INSERT' ? add : remove + ' ' + add + const body = + mysql && event === 'UPDATE' ? `BEGIN IF ${changed} THEN ${statements} END IF; END` : `BEGIN ${statements} END` + const qualifier = mysql ? ' FOR EACH ROW' : event === 'UPDATE' ? ` WHEN ${changed}` : '' + const sql = `CREATE TRIGGER ${name} AFTER ${event} ON ${table}${qualifier} ${body}` + if (mysql) { + const [rows]: Array> = await k.raw( + 'SELECT EVENT_MANIPULATION AS event, ACTION_TIMING AS timing, EVENT_OBJECT_TABLE AS tableName, ACTION_STATEMENT AS body FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME = ?', + [name] + ) + if (!Array.isArray(rows)) throw new WERR_INVALID_OPERATION('Invalid snapshot profile trigger metadata') + if (rows.length !== 0) { + const row = rows[0] + if ( + rows.length !== 1 || + row === undefined || + row.event !== event || + row.timing !== 'AFTER' || + row.tableName !== table || + normalized(row.body) !== normalized(body) + ) { + throw new WERR_INVALID_OPERATION('Snapshot profile trigger definition mismatch') + } + return + } + } else { + const row: { sql: string } | undefined = await k('sqlite_master').where({ type: 'trigger', name }).first('sql') + if (row !== undefined) { + if (normalized(row.sql) !== normalized(sql)) + throw new WERR_INVALID_OPERATION('Snapshot profile trigger definition mismatch') + return + } + } + if (create) await k.raw(sql) +} + +async function bootstrapTable(k: Knex, table: string, key: string, tableId: number): Promise { + await k(PROGRESS) + .insert({ snapshotTableId: tableId, afterRowId: 0, complete: false }) + .onConflict('snapshotTableId') + .ignore() + let complete = false + while (!complete) { + complete = await k.transaction(async trx => { + // SQLite must acquire its writer lock before reading a resumable position. + // MySQL takes a current row lock without loading an older read-view value. + if (!isMySQL(k)) + await trx(PROGRESS) + .where('snapshotTableId', tableId) + .update({ afterRowId: trx.ref('afterRowId') }) + const query = trx(PROGRESS).where('snapshotTableId', tableId) + if (isMySQL(k)) void query.forUpdate() + const state: { afterRowId: number; complete: boolean | number } | undefined = await query.first() + if ( + state === undefined || + !Number.isSafeInteger(state.afterRowId) || + state.afterRowId < 0 || + ![false, true, 0, 1].includes(state.complete) + ) { + throw new WERR_INVALID_OPERATION('Invalid snapshot profile bootstrap position') + } + if (state.complete === true || state.complete === 1) return true + const source = trx(table).select(key, 'userId').where(key, '>', state.afterRowId).orderBy(key).limit(PAGE_ROWS) + if (isMySQL(k)) void source.forUpdate() + const rows: Array> = await source + const keys = rows.map(row => { + const rowId = row[key] + const userId = row.userId + if ( + rowId === undefined || + userId === undefined || + !Number.isSafeInteger(rowId) || + rowId < 1 || + !Number.isSafeInteger(userId) || + userId < 1 + ) { + throw new WERR_INVALID_OPERATION('Invalid snapshot profile source key') + } + return { snapshotTableId: tableId, snapshotUserId: userId, snapshotRowId: rowId } + }) + if (keys.length !== 0) + await trx(KEYS).insert(keys).onConflict(['snapshotTableId', 'snapshotUserId', 'snapshotRowId']).ignore() + const finished = rows.length < PAGE_ROWS + await trx(PROGRESS) + .where('snapshotTableId', tableId) + .update({ afterRowId: keys.at(-1)?.snapshotRowId ?? state.afterRowId, complete: finished }) + return finished + }) + } +} + +/** Add auxiliary keys without altering any standard-table index or OFFSET plan. */ +export async function addSnapshotProfileIndexes(k: Knex): Promise { + if (isMySQL(k) && k.isTransaction) + throw new WERR_INVALID_OPERATION('Snapshot profile migration requires independent DDL and bootstrap transactions') + if (!(await k.schema.hasTable(KEYS))) { + await k.schema.createTable(KEYS, t => { + t.integer('snapshotTableId').notNullable() + t.integer('snapshotUserId').unsigned().notNullable() + t.integer('snapshotRowId').unsigned().notNullable() + t.primary(['snapshotTableId', 'snapshotUserId', 'snapshotRowId']) + }) + } + await validateTable(k, KEYS) + if (!(await k.schema.hasTable(PROGRESS))) { + await k.schema.createTable(PROGRESS, t => { + t.integer('snapshotTableId').primary() + t.integer('afterRowId').unsigned().notNullable() + t.boolean('complete').notNullable() + }) + } + await validateTable(k, PROGRESS) + for (const [tableId, { table, key }] of snapshotProfileTables.entries()) { + // Deletion must be observed before any partial installation can add keys. + await installTrigger(k, table, key, tableId, 'DELETE') + await installTrigger(k, table, key, tableId, 'UPDATE') + await installTrigger(k, table, key, tableId, 'INSERT') + await bootstrapTable(k, table, key, tableId) + } +} + +/** Call only after snapshot readers are drained; standard rows remain intact. */ +export async function removeSnapshotProfileIndexes(k: Knex): Promise { + if (isMySQL(k) && k.isTransaction) + throw new WERR_INVALID_OPERATION('Snapshot profile migration requires independent DDL and bootstrap transactions') + if (await k.schema.hasTable(KEYS)) await validateTable(k, KEYS) + if (await k.schema.hasTable(PROGRESS)) await validateTable(k, PROGRESS) + for (const [tableId, { table, key }] of snapshotProfileTables.entries()) { + for (const event of ['INSERT', 'UPDATE', 'DELETE'] as const) + await installTrigger(k, table, key, tableId, event, false) + } + for (const [tableId] of snapshotProfileTables.entries()) { + // Stop every producer before removing the last deletion observer. + for (const event of ['insert', 'update', 'delete']) + await k.raw(`DROP TRIGGER IF EXISTS snapshot_profile_${tableId}_${event}`) + } + await k.schema.dropTableIfExists(PROGRESS) + await k.schema.dropTableIfExists(KEYS) +} + +/** Read only within the retained view that owns this snapshot's header and pages. */ +export async function readSnapshotProfileIndexState(k: Knex, config?: Knex.MigratorConfig): Promise { + const tableName = config?.tableName ?? 'knex_migrations' + const journalSchema = k.schema + if (config?.schemaName !== undefined) void journalSchema.withSchema(config.schemaName) + if (!(await journalSchema.hasTable(tableName))) return false + const journal = k(tableName).where('name', SNAPSHOT_PROFILE_INDEX_MIGRATION) + if (config?.schemaName !== undefined) void journal.withSchema(config.schemaName) + if ((await journal.first('name')) === undefined) return false + if (!(await k.schema.hasTable(KEYS)) || !(await k.schema.hasTable(PROGRESS))) { + throw new WERR_INVALID_OPERATION('Snapshot profile index migration is incomplete') + } + await validateTable(k, KEYS) + await validateTable(k, PROGRESS) + const rows: Array<{ snapshotTableId: number; afterRowId: number; complete: unknown }> = await k(PROGRESS) + .select('snapshotTableId', 'afterRowId', 'complete') + .orderBy('snapshotTableId') + .limit(snapshotProfileTables.length + 1) + if ( + rows.length !== snapshotProfileTables.length || + rows.some( + (row, index) => + row.snapshotTableId !== index || + !Number.isSafeInteger(row.afterRowId) || + row.afterRowId < 0 || + (row.complete !== true && row.complete !== 1) + ) + ) + throw new WERR_INVALID_OPERATION('Snapshot profile index migration is incomplete') + return true +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts index 652846821..aa74a9c6d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -67,7 +67,7 @@ test('a ready request waits for owned reader destruction while foreground storag const source = (await storage.openSnapshotArchiveSource(identity))! expect(source.user.identityKey).toBe(identity) expect(source.sourceStorage.storageIdentityKey).toBe('original-source') - expect(source.sourceSchema).toBe('2026-10-01-002 add snapshot archive source guards') + expect(source.sourceSchema).toBe('2026-10-01-003 add snapshot profile key indexes') const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex expect(reader.knex).not.toBe(storage.knex) expect(reader.knex.client.config.pool).toMatchObject({ min: 0, max: 1 }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts index 697b8772d..f6e113dac 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts @@ -23,7 +23,14 @@ function fixture(fieldCount = 6, bytes = 524, certificate = false, deleted: bool callback: (error: Error | null, rows?: unknown[], fields?: unknown[]) => void ) { queries.push({ sql: query.sql, bindings }) - if (query.sql.startsWith('SELECT COLUMN_NAME')) { + if (query.sql.startsWith('select * from information_schema.tables')) { + // This fixture represents an unmigrated externally supplied pool. + expect(query.sql).toBe( + 'select * from information_schema.tables where table_name = ? and table_schema = database()' + ) + expect(bindings).toEqual(['knex_migrations']) + callback(null, [], []) + } else if (query.sql.startsWith('SELECT COLUMN_NAME')) { expect(query.sql).toBe( 'SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION LIMIT 65' ) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts index 4bb2d5e3c..a6bf6f4df 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts @@ -1,3 +1,4 @@ +import { removeSnapshotProfileIndexes, SNAPSHOT_PROFILE_INDEX_MIGRATION } from '../schema/snapshotProfileIndexMigration' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -519,31 +520,46 @@ test('concurrent reads refuse instead of queueing and expiry invalidates cursors await view.closed }) -test('existing SQL keys support forward seeks for numeric and composite pages without OFFSET or full counts', async () => { - const { source, userId, otherId } = await fixture() - await seedClosure(source, userId, otherId) - const view = await source.openWalletReadSnapshot(identity) - const requests: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] - const collect = (q: { sql: string; bindings: Knex.RawBinding[] }) => { - if (q.sql.startsWith('select')) requests.push(q) - } - source.knex.on('query', collect) - for (const table of ['txLabels', 'txLabelMaps', 'certificateFields'] as const) { - const first = await view.readPage(table, undefined, { maxRows: 1 }) - await view.readPage(table, first.cursor, { maxRows: 1 }) - } - source.knex.removeListener('query', collect) - await view.close() - const subsequent = requests.filter(q => q.sql.includes(' > ')) - expect(subsequent).toHaveLength(6) - for (const query of subsequent) { - expect(query.sql).not.toMatch(/offset|count\(/i) - const plan = await source.knex.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) - expect(plan.map((row: { detail: string }) => row.detail).join('\n')).toMatch( - /SEARCH (tx_labels|tx_labels_map|certificate_fields) USING .*\(.*>\(?\?/ - ) +test.each([false, true])( + 'SQL keys support numeric and composite forward seeks without OFFSET or full counts (profileIndexes=%s)', + async profileIndexes => { + const { source, userId, otherId } = await fixture() + if (!profileIndexes) { + await removeSnapshotProfileIndexes(source.knex) + await source.knex('knex_migrations').where('name', SNAPSHOT_PROFILE_INDEX_MIGRATION).delete() + } + await seedClosure(source, userId, otherId) + const view = await source.openWalletReadSnapshot(identity) + const requests: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] + const collect = (q: { sql: string; bindings: Knex.RawBinding[] }) => { + if (q.sql.startsWith('select')) requests.push(q) + } + source.knex.on('query', collect) + for (const table of ['txLabels', 'txLabelMaps', 'certificateFields'] as const) { + const first = await view.readPage(table, undefined, { maxRows: 1 }) + await view.readPage(table, first.cursor, { maxRows: 1 }) + } + source.knex.removeListener('query', collect) + await view.close() + const subsequent = requests.filter(q => q.sql.includes(' > ')) + expect(subsequent).toHaveLength(6) + expect(subsequent.filter(query => query.sql.includes('snapshot_profile_keys'))).toHaveLength(profileIndexes ? 2 : 0) + for (const query of subsequent) { + expect(query.sql).not.toMatch(/offset|count\(/i) + const plan = await source.knex.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) + const details = plan.map((row: { detail: string }) => row.detail).join('\n') + if (query.sql.includes('snapshot_profile_keys')) { + expect(details).toMatch( + /SEARCH snapshot_profile_keys USING COVERING INDEX .*\(snapshotTableId=\? AND snapshotUserId=\? AND snapshotRowId>\?(?: AND snapshotRowId<\?)?\)/ + ) + expect(details).toMatch(/SEARCH tx_labels USING INTEGER PRIMARY KEY \(rowid=\?\)/) + expect(details).not.toMatch(/SCAN |TEMP B-TREE/) + } else { + expect(details).toMatch(/SEARCH (tx_labels|tx_labels_map|certificate_fields) USING .*\(.*>\(?\?/) + } + } } -}) +) test('an owned certificate with a foreign-user field rejects without returning that field', async () => { const { source, userId, otherId } = await fixture() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index 65e153ee8..23e451cc6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -1,3 +1,4 @@ +import { readSnapshotProfileIndexState } from '../schema/snapshotProfileIndexMigration' import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' import { Random, Utils } from '@bsv/sdk' import type { Knex } from 'knex' @@ -23,8 +24,8 @@ interface TableDefinition { dates?: string[] } -// Use existing unique keys. This additive path does not add indexes or change -// traversal order for legacy OFFSET checkpoints. Map/field order is explicitly +// Preserve existing logical keys and the standard-table indexes used by legacy +// OFFSET checkpoints. Auxiliary profile keys retain the same snapshot order. Map/field order is explicitly // storage-key order, not the canonical BRC-38 array order. const definitions: Record = { provenTxs: { name: 'proven_txs', keys: ['provenTxId'] }, @@ -42,6 +43,17 @@ const definitions: Record = { syncStates: { name: 'sync_states', keys: ['syncStateId'], booleans: ['init'], dates: ['when'] } } +const auxiliaryTableIds: Partial> = { + transactions: 0, + outputs: 1, + certificates: 2, + txLabels: 3, + outputBaskets: 4, + outputTags: 5, + commissions: 6, + syncStates: 7 +} + function definition(table: WalletSnapshotTable): TableDefinition { if (!Object.hasOwn(definitions, table)) throw new WERR_INVALID_PARAMETER('table', 'a wallet snapshot table') return definitions[table] @@ -96,8 +108,19 @@ function owned(k: Knex, table: string, id: string, source: string, userId: numbe } /** Shared profile selection for local paging and archive closure checks. */ -export function walletSnapshotSourceQuery(k: Knex, table: WalletSnapshotTable, userId: number): Knex.QueryBuilder { +export function walletSnapshotSourceQuery( + k: Knex, + table: WalletSnapshotTable, + userId: number, + profileIndexes = false +): Knex.QueryBuilder { const { name } = definitions[table] + const tableId = auxiliaryTableIds[table] + if (profileIndexes && tableId !== undefined) + return k('snapshot_profile_keys') + .crossJoin(name, 'snapshotRowId', `${name}.${definitions[table].keys[0]}`) + .where({ snapshotTableId: tableId, snapshotUserId: userId }) + .where(`${name}.userId`, userId) const query = k(name) if (table === 'provenTxReqs') { return query.whereExists(owned(k, 'transactions', 'txid', `${name}.txid`, userId)) @@ -204,6 +227,7 @@ interface SnapshotContext { storage: StorageKnex userId: number snapshotId: string + profileIndexes: boolean columns: Map } @@ -238,7 +262,7 @@ async function readPage( after: Array | undefined, limits: { maxRows: number; maxBytes: number } ): Promise> { - const { storage, userId, columns, snapshotId } = context + const { storage, userId, columns, snapshotId, profileIndexes } = context const k = storage.toDb(trx) const schema = definitions[table] let fields = columns.get(table) @@ -247,10 +271,11 @@ async function readPage( if (fields.length === 0 || fields.length > 64) throw new WERR_INVALID_OPERATION('Unsupported snapshot schema') columns.set(table, fields) } + const orderKeys = !profileIndexes || auxiliaryTableIds[table] === undefined ? schema.keys : ['snapshotRowId'] const base = (): Knex.QueryBuilder => { - const q = walletSnapshotSourceQuery(k, table, userId) - if (after !== undefined) seek(q, schema.keys, after) - for (const key of schema.keys) void q.orderBy(key) + const q = walletSnapshotSourceQuery(k, table, userId, profileIndexes) + if (after !== undefined) seek(q, orderKeys, after) + for (const key of orderKeys) void q.orderBy(key) return q } const keyColumns = schema.keys.map(key => keyColumn(k, key, storage.dbtype === 'MySQL')) @@ -268,7 +293,7 @@ async function readPage( let rows: Array> = [] if (end !== undefined) { const query = base().select(`${schema.name}.*`).limit(count) - seek(query, schema.keys, end, true) + seek(query, orderKeys, end, true) const raw: Array> = await query rows = raw.map(row => normalize(storage, row, schema)) } @@ -280,9 +305,10 @@ export function createKnexWalletSnapshotPageReader( storage: StorageKnex, userId: number, snapshotId: string, - view: RetainedReadSnapshot + view: RetainedReadSnapshot, + profileIndexes = false ): WalletReadSnapshot['readPage'] { - const context: SnapshotContext = { storage, userId, snapshotId, columns: new Map() } + const context: SnapshotContext = { storage, userId, snapshotId, profileIndexes, columns: new Map() } return async ( table: T, cursor?: WalletSnapshotCursor, @@ -307,11 +333,14 @@ export async function openKnexWalletReadSnapshot( } const view = await storage.openReadSnapshot(options) try { - const header = await view.read(async trx => { + const { header, profileIndexes } = await view.read(async trx => { const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') - return { sourceStorage, user } + return { + header: { sourceStorage, user }, + profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations) + } }) const userId = header.user.userId const snapshotId = Utils.toHex(Random(32)) @@ -325,7 +354,7 @@ export async function openKnexWalletReadSnapshot( }, closed: view.closed, close: view.close, - readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view) + readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view, profileIndexes) } } catch (error) { // Keep the opening error authoritative while still awaiting physical cleanup. diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts index a95c329e2..28023e7b1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts @@ -1,3 +1,8 @@ +import { + addSnapshotProfileIndexes, + removeSnapshotProfileIndexes, + snapshotProfileTables +} from '../schema/snapshotProfileIndexMigration' import fc from 'fast-check' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -249,3 +254,79 @@ test('random schedules preserve single-read admission, late-result rejection and }) ) }) + +test('generated source writes, profile moves, rollback and restart preserve exact auxiliary membership', async () => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + try { + await runInSeries(snapshotProfileTables, async ({ table, key }) => { + await k.schema.createTable(table, columns => { + columns.integer(key).primary() + columns.integer('userId').notNullable() + columns.text('value') + }) + }) + await addSnapshotProfileIndexes(k) + const verify = async (): Promise => { + const expected: Array<{ snapshotTableId: number; snapshotUserId: number; snapshotRowId: number }> = [] + await runInSeries(snapshotProfileTables.entries(), async ([snapshotTableId, { table, key }]) => { + const rows: Array> = await k(table).select(key, 'userId') + expected.push(...rows.map(row => ({ snapshotTableId, snapshotUserId: row.userId, snapshotRowId: row[key] }))) + }) + expected.sort( + (a, b) => + a.snapshotTableId - b.snapshotTableId || + a.snapshotUserId - b.snapshotUserId || + a.snapshotRowId - b.snapshotRowId + ) + expect(await k('snapshot_profile_keys').orderBy(['snapshotTableId', 'snapshotUserId', 'snapshotRowId'])).toEqual( + expected + ) + } + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + tableId: fc.integer({ min: 0, max: 7 }), + rowId: fc.integer({ min: 1, max: 20 }), + owner: fc.integer({ min: 1, max: 4 }), + kind: fc.constantFrom('insert-or-update', 'delete', 'rollback', 'rebuild', 'repeat'), + value: fc.string({ maxLength: 12 }) + }), + { minLength: 1, maxLength: 12 } + ), + async schedule => { + await runInSeries(snapshotProfileTables, async ({ table }) => { + await k(table).delete() + }) + await verify() + await runInSeries(schedule, async operation => { + const { table, key } = snapshotProfileTables[operation.tableId] + const row = { [key]: operation.rowId, userId: operation.owner, value: operation.value } + if (operation.kind === 'insert-or-update') await k(table).insert(row).onConflict(key).merge() + else if (operation.kind === 'delete') await k(table).where(key, operation.rowId).delete() + else if (operation.kind === 'rollback') { + const rollback = new Error('synthetic rollback') + await expect( + k.transaction(async trx => { + await trx(table).insert(row).onConflict(key).merge() + throw rollback + }) + ).rejects.toBe(rollback) + } else if (operation.kind === 'rebuild') { + await removeSnapshotProfileIndexes(k) + await addSnapshotProfileIndexes(k) + } else await addSnapshotProfileIndexes(k) + await verify() + }) + } + ) + ) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts new file mode 100644 index 000000000..459bc996a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts @@ -0,0 +1,206 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex, type Knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { runInSeries } from '../../utility/runInSeries' +import { seedArchiveClosure } from '../../../test/utils/snapshotArchiveFixtures' +import { snapshotArchiveTables } from './archive/SnapshotArchive' +import { openKnexSnapshotArchiveSource, type SnapshotArchiveSource } from './archive/KnexSnapshotArchiveSource' +import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' +import { + addSnapshotProfileIndexes, + removeSnapshotProfileIndexes, + SNAPSHOT_PROFILE_INDEX_MIGRATION +} from '../schema/snapshotProfileIndexMigration' + +const identity = '02' + '11'.repeat(32) +const stores: StorageKnex[] = [] +const writers: Knex[] = [] +const directories: string[] = [] +const dates = { created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z' } +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-profile-index-')) + directories.push(directory) + const options = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + } + const k = knex(options) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + stores.push(source) + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('profile index fixture', 'synthetic-profile-index') + await source.makeAvailable() + // Start with the immediately preceding schema on both old and new source. + await removeSnapshotProfileIndexes(k) + await k('knex_migrations').where('name', SNAPSHOT_PROFILE_INDEX_MIGRATION).delete() + const { user } = await source.findOrInsertUser(identity) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, other.userId) + const writer = knex(options) + writers.push(writer) + return { source, writer, k, userId: user.userId, otherId: other.userId } +} +async function journal(k: Knex): Promise { + await k('knex_migrations').insert({ name: SNAPSHOT_PROFILE_INDEX_MIGRATION, batch: 99, migration_time: new Date() }) +} +async function pages(view: WalletReadSnapshot | SnapshotArchiveSource) { + expect(Object.hasOwn(view, 'profileIndexes')).toBe(false) + if ('validateClosure' in view) await view.validateClosure() + const result: Record = {} + await runInSeries(snapshotArchiveTables, async table => { + let cursor: WalletSnapshotCursor | undefined + const selected: unknown[] = [] + let complete = false + for (let pageNumber = 0; pageNumber < 25 && !complete; pageNumber++) { + const page = await view.readPage(table, cursor, { maxRows: 1, maxBytes: 131072 }) + selected.push({ rows: page.rows, after: page.cursor?.after, payloadBytes: page.payloadBytes, done: page.done }) + complete = page.done + cursor = page.cursor + } + expect(complete).toBe(true) + result[table] = selected + }) + return result +} +afterEach(async () => { + await runInSeries(writers.splice(0), k => k.destroy()) + await runInSeries(stores.splice(0), source => source.destroy()) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) + +test('indexed ordinary/archive pages preserve all13tables and original source indexes/legacy offsets', async () => { + const { source, k, userId } = await fixture() + const legacy = async () => { + const all: Record = {} + await runInSeries( + [ + ['findTransactions', 'transactionId'], + ['findOutputs', 'outputId'], + ['findCertificates', 'certificateId'], + ['findTxLabels', 'txLabelId'], + ['findOutputBaskets', 'basketId'], + ['findOutputTags', 'outputTagId'], + ['findCommissions', 'commissionId'], + ['findSyncStates', 'syncStateId'] + ] as const, + async ([method, key]) => { + all[method] = [] + await runInSeries([0, 1], async offset => { + const rows = await source[method]({ partial: { userId }, paged: { limit: 1, offset } }) + all[method].push(rows.map(row => (row as unknown as Record)[key])) + }) + } + ) + return all + } + const standard = async () => + await k('sqlite_master') + .whereIn('type', ['table', 'index']) + .whereNotIn('tbl_name', ['snapshot_profile_keys', 'snapshot_profile_index_progress']) + .select('type', 'name', 'tbl_name', 'sql') + .orderBy('name') + const originalIndexes = await standard() + const originalOffsets = await legacy() + const old = await source.openWalletReadSnapshot(identity) + let baseline + try { + baseline = await pages(old) + } finally { + await old.close() + } + await addSnapshotProfileIndexes(k) + expect(await standard()).toEqual(originalIndexes) + expect(await legacy()).toEqual(originalOffsets) + const queries: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] + const listen = (query: { sql: string; bindings: Knex.RawBinding[] }): void => { + if (query.sql.startsWith('select') && query.sql.includes('cross join')) queries.push(query) + } + k.on('query', listen) + try { + const partial = await source.openWalletReadSnapshot(identity) + try { + expect(await pages(partial)).toEqual(baseline) + expect(queries).toEqual([]) + } finally { + await partial.close() + } + await journal(k) + await runInSeries( + [() => source.openWalletReadSnapshot(identity), () => openKnexSnapshotArchiveSource(source, identity)], + async open => { + const view = await open() + try { + expect(await pages(view)).toEqual(baseline) + } finally { + await view.close() + } + } + ) + expect(queries.length).toBeGreaterThan(0) + } finally { + k.off('query', listen) + } + // Explain only page queries; closure may legitimately visit its relational parents. + await runInSeries( + queries.filter(query => query.sql.includes('__snapshotBytes') || query.sql.includes('.*')), + async query => { + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) + expect(plan.some(row => row.detail.includes('SEARCH snapshot_profile_keys USING COVERING INDEX'))).toBe(true) + expect(plan.some(row => /SCAN |TEMP B-TREE/.test(row.detail))).toBe(false) + } + ) +}) + +test.each(['ordinary', 'archive'] as const)( + '%s mode and all13table pages stay bound across independent journal/profile/progress writes', + async kind => { + const { source, k, writer, userId, otherId } = await fixture() + await addSnapshotProfileIndexes(k) + const open = async () => + kind === 'ordinary' + ? await source.openWalletReadSnapshot(identity) + : await openKnexSnapshotArchiveSource(source, identity) + const queries: string[] = [] + const listen = (query: { sql: string }): void => { + if (query.sql.startsWith('select') && query.sql.includes('cross join')) queries.push(query.sql) + } + k.on('query', listen) + const old = await open() + let baseline + try { + baseline = await pages(old) + queries.length = 0 + await journal(writer) + expect(await pages(old)).toEqual(baseline) + expect(queries).toEqual([]) + } finally { + await old.close() + } + const indexed = await open() + try { + await writer.transaction(async trx => { + await trx('snapshot_profile_index_progress').where('snapshotTableId', 3).update({ complete: 0 }) + await trx('tx_labels').where('txLabelId', 3).update({ userId: otherId, label: 'moved' }) + await trx('tx_labels').insert({ ...dates, txLabelId: 4, userId, label: 'new after view', isDeleted: false }) + }) + expect(await pages(indexed)).toEqual(baseline) + expect(queries.length).toBeGreaterThan(0) + } finally { + await indexed.close() + k.off('query', listen) + } + await expect(open()).rejects.toThrow('migration is incomplete') + await writer('snapshot_profile_index_progress').where('snapshotTableId', 3).update({ complete: 1 }) + const fresh = await open() + try { + expect((await fresh.readPage('txLabels')).rows.map(row => row.txLabelId)).toEqual([1, 4]) + } finally { + await fresh.close() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts new file mode 100644 index 000000000..4a24078eb --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { KnexMigrations } from '../schema/KnexMigrations' +import { + readSnapshotProfileIndexState, + SNAPSHOT_PROFILE_INDEX_MIGRATION +} from '../schema/snapshotProfileIndexMigration' + +test('registered profile bootstrap survives reopening and publishes its journal only after all pages commit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-profile-migration-')) + const options = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + } + let database = knex(options) + let source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: database }) + const migrationSource = new KnexMigrations('test', 'profile migration', 'synthetic-profile-migration', 1024) + try { + expect((await migrationSource.getMigration(SNAPSHOT_PROFILE_INDEX_MIGRATION)).config).toEqual({ + transaction: false + }) + await source.migrate('profile migration', 'synthetic-profile-migration') + await source.makeAvailable() + await database.migrate.down({ migrationSource, name: SNAPSHOT_PROFILE_INDEX_MIGRATION, disableTransactions: false }) + const priorJournal = await database('knex_migrations').orderBy('id') + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + for (let batch = 0; batch < 3; batch++) { + await database('tx_labels').insert( + Array.from({ length: 200 }, (_, index) => ({ + userId: user.userId, + label: `label-${batch * 200 + index}`, + isDeleted: false, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01') + })) + ) + } + let pages = 0 + const failure = new Error('synthetic interrupted profile bootstrap') + const interrupt = (query: { sql: string }): void => { + if (query.sql.startsWith('select `txLabelId`, `userId` from `tx_labels`') && ++pages === 2) throw failure + } + database.on('query', interrupt) + try { + await expect(source.migrate('profile migration', 'synthetic-profile-migration')).rejects.toBe(failure) + } finally { + database.off('query', interrupt) + } + expect(await database('knex_migrations').orderBy('id')).toEqual(priorJournal) + expect(await database('snapshot_profile_index_progress').where('snapshotTableId', 3).first()).toEqual({ + snapshotTableId: 3, + afterRowId: 256, + complete: 0 + }) + expect(await readSnapshotProfileIndexState(database)).toBe(false) + expect((await database.raw('PRAGMA foreign_keys'))[0].foreign_keys).toBe(1) + await source.destroy() + database = knex(options) + source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: database }) + await expect(source.migrate('profile migration', 'synthetic-profile-migration')).resolves.toBe( + SNAPSHOT_PROFILE_INDEX_MIGRATION + ) + expect(await readSnapshotProfileIndexState(database)).toBe(true) + expect(await database('snapshot_profile_keys').where('snapshotTableId', 3).count({ count: '*' }).first()).toEqual({ + count: 600 + }) + expect( + await database('knex_migrations').where('name', SNAPSHOT_PROFILE_INDEX_MIGRATION).count({ count: '*' }).first() + ).toEqual({ count: 1 }) + await database.migrate.down({ migrationSource, name: SNAPSHOT_PROFILE_INDEX_MIGRATION, disableTransactions: false }) + expect(await database.schema.hasTable('snapshot_profile_keys')).toBe(false) + expect(await database.schema.hasTable('snapshot_profile_index_progress')).toBe(false) + expect(await database('knex_migrations').orderBy('id')).toEqual(priorJournal) + expect(await database('tx_labels').count({ count: '*' }).first()).toEqual({ count: 600 }) + } finally { + await source.destroy() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.mysql.test.ts new file mode 100644 index 000000000..c466ce9f5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.mysql.test.ts @@ -0,0 +1,315 @@ +import { knex } from 'knex' +import { + addSnapshotProfileIndexes, + removeSnapshotProfileIndexes, + readSnapshotProfileIndexState, + SNAPSHOT_PROFILE_INDEX_MIGRATION, + snapshotProfileTables +} from '../schema/snapshotProfileIndexMigration' + +type MetadataKind = 'columns' | 'indexes' | 'triggers' +type Metadata = Array> + +/** Installed MySQL compiler and transaction protocol; native fixtures separately prove database semantics. */ +function mysqlFixture(change: (kind: MetadataKind, rows: Metadata) => unknown = (_kind, rows) => rows) { + const k = knex({ client: 'mysql2' }) + const queries: Array<{ sql: string; values: unknown[] }> = [] + const tables = new Set(['knex_migrations']) + const triggers = new Map>() + const progress = new Map() + const keys: Array<{ snapshotTableId: number; snapshotUserId: number; snapshotRowId: number }> = [] + let journaled = false + const answer = (sql: string, values: unknown[]): unknown => { + queries.push({ sql, values }) + if (sql.startsWith('select * from information_schema.tables')) + return tables.has(String(values[0])) ? [{ TABLE_NAME: values[0] }] : [] + if (sql.startsWith('create table')) { + tables.add(sql.match(/^create table `([^`]+)`/)![1]) + return [] + } + if (sql.startsWith('drop table')) { + tables.delete(sql.match(/`([^`]+)`/)![1]) + return [] + } + if (sql.startsWith('SELECT COLUMN_NAME')) { + const isKeys = values[0] === 'snapshot_profile_keys' + const names = isKeys + ? ['snapshotTableId', 'snapshotUserId', 'snapshotRowId'] + : ['snapshotTableId', 'afterRowId', 'complete'] + const types = isKeys ? ['int', 'int unsigned', 'int unsigned'] : ['int', 'int unsigned', 'tinyint'] + return change( + 'columns', + names.map((name, i) => ({ name, type: types[i], nullable: 'NO', defaultValue: null, extra: '' })) + ) + } + if (sql.startsWith('SELECT INDEX_NAME')) { + return change( + 'indexes', + (values[0] === 'snapshot_profile_keys' + ? ['snapshotTableId', 'snapshotUserId', 'snapshotRowId'] + : ['snapshotTableId'] + ).map(columnName => ({ name: 'PRIMARY', columnName, nonUnique: 0, direction: 'A', prefix: null })) + ) + } + if (sql.startsWith('SELECT EVENT_MANIPULATION')) + return change('triggers', triggers.has(String(values[0])) ? [triggers.get(String(values[0]))!] : []) + if (sql.startsWith('CREATE TRIGGER')) { + const [, name, event, tableName, body] = sql.match( + /^CREATE TRIGGER (\w+) AFTER (\w+) ON (\w+) FOR EACH ROW (.*)$/ + )! + triggers.set(name, { event, timing: 'AFTER', tableName, body }) + return [] + } + if (sql.startsWith('DROP TRIGGER')) { + triggers.delete(sql.split(' ').at(-1)!) + return [] + } + if (sql.startsWith('insert ignore into `snapshot_profile_index_progress`')) { + const [afterRowId, complete, snapshotTableId] = values as [number, boolean, number] + if (!progress.has(snapshotTableId)) progress.set(snapshotTableId, { snapshotTableId, afterRowId, complete }) + return { affectedRows: 1, insertId: 0 } + } + if (sql.startsWith('select * from `snapshot_profile_index_progress`')) { + expect(sql).toMatch(/for update$/) + const row = progress.get(Number(values[0])) + return row === undefined ? [] : [{ ...row }] + } + if (sql.startsWith('select `snapshotTableId`, `afterRowId`, `complete`')) + return [...progress.values()].map(row => ({ ...row })).slice(0, Number(values[0])) + if (sql.startsWith('update `snapshot_profile_index_progress`')) { + const [afterRowId, complete, tableId] = values as [number, boolean, number] + Object.assign(progress.get(tableId)!, { afterRowId, complete }) + return { affectedRows: 1 } + } + if (sql.startsWith('insert ignore into `snapshot_profile_keys`')) { + for (let i = 0; i < values.length; i += 3) { + const [snapshotRowId, snapshotTableId, snapshotUserId] = values.slice(i, i + 3) as number[] + keys.push({ snapshotTableId, snapshotUserId, snapshotRowId }) + } + return { affectedRows: values.length / 3, insertId: 0 } + } + if (sql.startsWith('select `name` from `knex_migrations`')) + return journaled ? [{ name: SNAPSHOT_PROFILE_INDEX_MIGRATION }] : [] + if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return [] + const source = snapshotProfileTables.find(({ table }) => sql.includes('from `' + table + '`')) + if (source !== undefined) { + expect(sql).toMatch(/for update$/) + expect(values[1]).toBe(256) + return [ + { [source.key]: 1, userId: 1 }, + { [source.key]: 3, userId: 2 } + ].filter(row => row[source.key] > Number(values[0])) + } + throw new Error('Unexpected synthetic driver query: ' + sql) + } + const connection = { + query( + query: { sql: string }, + values: unknown[], + callback: (error: Error | null, rows?: unknown, fields?: unknown[]) => void + ) { + try { + callback(null, answer(query.sql, values), []) + } catch (error) { + callback(error as Error) + } + } + } + jest.spyOn(k.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(k.client, 'releaseConnection').mockResolvedValue(undefined) + return { + k, + queries, + keys, + progress, + triggers, + tables, + journal: () => { + journaled = true + } + } +} + +afterEach(() => jest.restoreAllMocks()) + +test('MySQL DDL and locked bounded bootstrap preserve completion ordering and support repeat/removal', async () => { + const f = mysqlFixture() + try { + await addSnapshotProfileIndexes(f.k) + expect(f.triggers.size).toBe(24) + expect(f.keys).toEqual( + snapshotProfileTables.flatMap((_table, snapshotTableId) => [ + { snapshotTableId, snapshotUserId: 1, snapshotRowId: 1 }, + { snapshotTableId, snapshotUserId: 2, snapshotRowId: 3 } + ]) + ) + expect([...f.progress.values()]).toEqual( + snapshotProfileTables.map((_table, snapshotTableId) => ({ snapshotTableId, afterRowId: 3, complete: true })) + ) + expect( + f.queries.filter(query => query.sql.includes('for update') && !query.sql.includes('snapshot_profile')) + ).toHaveLength(8) + expect(f.queries.find(query => query.sql.startsWith('create table `snapshot_profile_keys`'))!.sql).toContain( + 'primary key (`snapshotTableId`, `snapshotUserId`, `snapshotRowId`)' + ) + expect(f.queries.filter(query => query.sql === 'BEGIN;')).toHaveLength(8) + expect(f.queries.filter(query => query.sql === 'COMMIT;')).toHaveLength(8) + f.journal() + expect(await readSnapshotProfileIndexState(f.k)).toBe(true) + await addSnapshotProfileIndexes(f.k) + expect(f.keys).toHaveLength(16) + await removeSnapshotProfileIndexes(f.k) + expect(f.triggers.size).toBe(0) + expect(f.tables.has('snapshot_profile_keys')).toBe(false) + } finally { + await f.k.destroy() + } +}) + +test.each([ + ['columns', []], + ['columns', null], + ['indexes', []], + ['indexes', null] +] as const)('MySQL missing/non-array %s metadata refuses before installing triggers', async (kind, replacement) => { + const f = mysqlFixture((current, rows) => (current === kind ? replacement : rows)) + try { + await expect(addSnapshotProfileIndexes(f.k)).rejects.toThrow('table definition mismatch') + expect(f.triggers.size).toBe(0) + expect(f.keys).toEqual([]) + } finally { + await f.k.destroy() + } +}) + +test.each([ + { name: 'unexpected' }, + { type: 'bigint' }, + { nullable: 'YES' }, + { defaultValue: 0 }, + { extra: 'auto_increment' } +])('MySQL column metadata mismatch %j refuses without bootstrap', async changed => { + const f = mysqlFixture((kind, rows) => (kind === 'columns' ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows)) + try { + await expect(addSnapshotProfileIndexes(f.k)).rejects.toThrow('table definition mismatch') + expect(f.keys).toEqual([]) + } finally { + await f.k.destroy() + } +}) + +test.each([{ direction: 'D' }, { prefix: 1 }, { columnName: 'unexpected' }])( + 'MySQL primary-key mismatch %j refuses adoption', + async changed => { + const f = mysqlFixture((kind, rows) => (kind === 'indexes' ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows)) + try { + await expect(addSnapshotProfileIndexes(f.k)).rejects.toThrow('table definition mismatch') + } finally { + await f.k.destroy() + } + } +) + +test('MySQL extra unique constraints refuse while nonunique indexes remain compatible', async () => { + for (const nonUnique of [0, 1]) { + const f = mysqlFixture((kind, rows) => + kind === 'indexes' ? [...rows, { name: 'extra', columnName: 'snapshotRowId', nonUnique }] : rows + ) + try { + if (nonUnique === 0) await expect(addSnapshotProfileIndexes(f.k)).rejects.toThrow('table definition mismatch') + else { + await addSnapshotProfileIndexes(f.k) + expect(f.keys).toHaveLength(16) + } + } finally { + await f.k.destroy() + } + } +}) + +test('a MySQL migration in an outer transaction refuses before DDL can commit it implicitly', async () => { + const f = mysqlFixture() + try { + await f.k.transaction(async trx => { + await expect(addSnapshotProfileIndexes(trx)).rejects.toThrow('independent DDL') + await expect(removeSnapshotProfileIndexes(trx)).rejects.toThrow('independent DDL') + }) + expect(f.queries.every(query => /^(BEGIN|COMMIT)/.test(query.sql))).toBe(true) + } finally { + await f.k.destroy() + } +}) + +test.each([ + { event: 'UNKNOWN' }, + { timing: 'BEFORE' }, + { tableName: 'foreign_table' }, + { body: 'BEGIN SELECT 1; END' } +])('MySQL stored trigger mismatch %j refuses adoption/removal without deleting definitions', async changed => { + let corrupt = false + const f = mysqlFixture((kind, rows) => (corrupt && kind === 'triggers' ? [{ ...rows[0], ...changed }] : rows)) + try { + await addSnapshotProfileIndexes(f.k) + corrupt = true + await expect(addSnapshotProfileIndexes(f.k)).rejects.toThrow('trigger definition mismatch') + await expect(removeSnapshotProfileIndexes(f.k)).rejects.toThrow('trigger definition mismatch') + expect(f.triggers.size).toBe(24) + expect(f.tables.has('snapshot_profile_keys')).toBe(true) + } finally { + await f.k.destroy() + } +}) + +test.each([null, [undefined], [{}, {}]])( + 'MySQL malformed trigger metadata %p refuses before creation', + async replacement => { + const f = mysqlFixture((kind, rows) => (kind === 'triggers' ? replacement : rows)) + try { + await expect(addSnapshotProfileIndexes(f.k)).rejects.toThrow(/trigger (metadata|definition mismatch)/) + expect(f.triggers.size).toBe(0) + expect(f.keys).toEqual([]) + } finally { + await f.k.destroy() + } + } +) + +test('MySQL native-width metadata and harmless body whitespace do not require recreation', async () => { + const f = mysqlFixture((kind, rows) => + kind === 'columns' + ? rows.map(row => ({ ...row, type: String(row.type).replace('int', 'int(11)') })) + : kind === 'triggers' + ? rows.map(row => ({ ...row, body: String(row.body).replace(/ /g, ' \n ') })) + : rows + ) + try { + await addSnapshotProfileIndexes(f.k) + const created = f.queries.filter(query => query.sql.startsWith('CREATE TRIGGER')).length + await addSnapshotProfileIndexes(f.k) + expect(f.queries.filter(query => query.sql.startsWith('CREATE TRIGGER'))).toHaveLength(created) + expect(f.keys).toHaveLength(16) + } finally { + await f.k.destroy() + } +}) + +test('pool-only MySQL uses its actual database and preserves the explicit custom journal schema', async () => { + const f = mysqlFixture() + try { + expect(await readSnapshotProfileIndexState(f.k)).toBe(false) + expect(f.queries[0]).toEqual({ + sql: 'select * from information_schema.tables where table_name = ? and table_schema = database()', + values: ['knex_migrations'] + }) + expect(await readSnapshotProfileIndexState(f.k, { tableName: 'custom_journal', schemaName: 'custom_schema' })).toBe( + false + ) + expect(f.queries.at(-1)).toEqual({ + sql: 'select * from information_schema.tables where table_name = ? and table_schema = ?', + values: ['custom_journal', 'custom_schema'] + }) + expect(f.keys).toEqual([]) + } finally { + await f.k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.test.ts new file mode 100644 index 000000000..beabc557e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.test.ts @@ -0,0 +1,273 @@ +import { knex, type Knex } from 'knex' +import { runInSeries } from '../../utility/runInSeries' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { + addSnapshotProfileIndexes, + removeSnapshotProfileIndexes, + snapshotProfileTables, + readSnapshotProfileIndexState, + SNAPSHOT_PROFILE_INDEX_MIGRATION +} from '../schema/snapshotProfileIndexMigration' + +const databases: Knex[] = [] +async function minimal(): Promise { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + databases.push(k) + await runInSeries(snapshotProfileTables, async ({ table, key }) => { + await k.schema.createTable(table, columns => { + columns.integer(key).primary() + columns.integer('userId').notNullable() + columns.text('value') + }) + }) + return k +} +afterEach(async () => { + jest.restoreAllMocks() + await runInSeries(databases.splice(0), k => k.destroy()) +}) + +test('bootstrap checkpoints exactly256rows and a failed next page resumes without losing independent low-ID writes', async () => { + const k = await minimal() + await runInSeries([0, 1, 2], async batch => { + await k('transactions').insert( + Array.from({ length: 200 }, (_, offset) => ({ + transactionId: batch * 200 + offset + 1, + userId: 1, + value: 'original' + })) + ) + }) + let reads = 0 + const failure = new Error('synthetic second page interruption') + const interrupt = (query: { sql: string }): void => { + if (query.sql.startsWith('select `transactionId`, `userId` from `transactions`') && ++reads === 2) throw failure + } + k.on('query', interrupt) + try { + await expect(addSnapshotProfileIndexes(k)).rejects.toBe(failure) + } finally { + k.off('query', interrupt) + } + expect(await k('snapshot_profile_index_progress').where('snapshotTableId', 0).first()).toEqual({ + snapshotTableId: 0, + afterRowId: 256, + complete: 0 + }) + expect(await k('snapshot_profile_keys')).toHaveLength(256) + await k('transactions').where('transactionId', 1).update({ userId: 2 }) + await k('transactions').where('transactionId', 2).delete() + await k('transactions').insert({ transactionId: 2, userId: 3, value: 'reinserted' }) + await addSnapshotProfileIndexes(k) + await addSnapshotProfileIndexes(k) + expect(await k('snapshot_profile_keys').orderBy('snapshotRowId')).toEqual( + (await k('transactions').orderBy('transactionId')).map(row => ({ + snapshotTableId: 0, + snapshotUserId: row.userId, + snapshotRowId: row.transactionId + })) + ) + expect(await k('snapshot_profile_index_progress').where('complete', 1)).toHaveLength(8) +}) + +test.each([0, 1, 255, 256, 257])('bootstrap preserves its exact final key for a source with %p rows', async count => { + const k = await minimal() + if (count > 0) + await k('tx_labels').insert(Array.from({ length: count }, (_, index) => ({ txLabelId: index + 1, userId: 1 }))) + await addSnapshotProfileIndexes(k) + expect(await k('snapshot_profile_keys')).toHaveLength(count) + expect(await k('snapshot_profile_index_progress').where('snapshotTableId', 3).first()).toEqual({ + snapshotTableId: 3, + afterRowId: count, + complete: 1 + }) +}) + +test('mismatched source triggers refuse both adoption and removal without deleting the foreign definition', async () => { + const k = await minimal() + await addSnapshotProfileIndexes(k) + await k.raw('DROP TRIGGER snapshot_profile_0_delete') + const sql = 'CREATE TRIGGER snapshot_profile_0_delete AFTER DELETE ON transactions BEGIN SELECT 1; END' + await k.raw(sql) + await expect(addSnapshotProfileIndexes(k)).rejects.toThrow('trigger definition mismatch') + await expect(removeSnapshotProfileIndexes(k)).rejects.toThrow('trigger definition mismatch') + expect(await k('sqlite_master').where({ type: 'trigger', name: 'snapshot_profile_0_delete' }).first('sql')).toEqual({ + sql + }) + expect(await k.schema.hasTable('snapshot_profile_keys')).toBe(true) +}) + +test('removing auxiliary state twice preserves every standard table/index definition and row', async () => { + const k = await minimal() + await k('transactions').insert({ transactionId: 5, userId: 2, value: 'preserved' }) + const definitions = async () => + await k('sqlite_master') + .whereIn('type', ['table', 'index']) + .select('type', 'name', 'tbl_name', 'sql') + .orderBy('name') + const before = await definitions() + await addSnapshotProfileIndexes(k) + await removeSnapshotProfileIndexes(k) + await removeSnapshotProfileIndexes(k) + expect(await definitions()).toEqual(before) + expect(await k('transactions')).toEqual([{ transactionId: 5, userId: 2, value: 'preserved' }]) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) +}) + +test('a wrong auxiliary primary key rejects before installing triggers or marking bootstrap complete', async () => { + const k = await minimal() + await k.schema.createTable('snapshot_profile_keys', columns => { + columns.integer('snapshotTableId').notNullable() + columns.integer('snapshotUserId').notNullable() + columns.integer('snapshotRowId').notNullable() + columns.primary(['snapshotUserId', 'snapshotRowId']) + }) + await expect(addSnapshotProfileIndexes(k)).rejects.toBeInstanceOf(WERR_INVALID_OPERATION) + expect(await k('snapshot_profile_keys')).toEqual([]) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) + expect(await k.schema.hasTable('snapshot_profile_index_progress')).toBe(false) +}) + +test('only an exact journaled migration with every completed table position selects the indexed path', async () => { + const k = await minimal() + expect(await readSnapshotProfileIndexState(k)).toBe(false) + await addSnapshotProfileIndexes(k) + expect(await readSnapshotProfileIndexState(k)).toBe(false) + await k.schema.createTable('custom_journal', columns => { + columns.increments('id') + columns.string('name').notNullable() + }) + const config = { tableName: 'custom_journal', schemaName: 'main' } + await k('custom_journal').insert({ name: 'future unrelated migration' }) + expect(await readSnapshotProfileIndexState(k, config)).toBe(false) + await k('custom_journal').insert({ name: SNAPSHOT_PROFILE_INDEX_MIGRATION }) + expect(await readSnapshotProfileIndexState(k, config)).toBe(true) + await k('snapshot_profile_index_progress').where('snapshotTableId', 3).update({ complete: 0 }) + await expect(readSnapshotProfileIndexState(k, config)).rejects.toThrow('migration is incomplete') + await k('snapshot_profile_index_progress').where('snapshotTableId', 3).update({ complete: 1, afterRowId: -1 }) + await expect(readSnapshotProfileIndexState(k, config)).rejects.toThrow('migration is incomplete') + await k('snapshot_profile_index_progress').where('snapshotTableId', 3).delete() + await expect(readSnapshotProfileIndexState(k, config)).rejects.toThrow('migration is incomplete') +}) + +test.each([ + [ + 'missing column', + 'CREATE TABLE snapshot_profile_keys (snapshotTableId INTEGER NOT NULL, snapshotUserId INTEGER NOT NULL, PRIMARY KEY(snapshotTableId, snapshotUserId))' + ], + [ + 'wrong type', + 'CREATE TABLE snapshot_profile_keys (snapshotTableId TEXT NOT NULL, snapshotUserId INTEGER NOT NULL, snapshotRowId INTEGER NOT NULL, PRIMARY KEY(snapshotTableId, snapshotUserId, snapshotRowId))' + ], + [ + 'nullable owner', + 'CREATE TABLE snapshot_profile_keys (snapshotTableId INTEGER NOT NULL, snapshotUserId INTEGER, snapshotRowId INTEGER NOT NULL, PRIMARY KEY(snapshotTableId, snapshotUserId, snapshotRowId))' + ], + [ + 'unexpected default', + 'CREATE TABLE snapshot_profile_keys (snapshotTableId INTEGER NOT NULL DEFAULT 0, snapshotUserId INTEGER NOT NULL, snapshotRowId INTEGER NOT NULL, PRIMARY KEY(snapshotTableId, snapshotUserId, snapshotRowId))' + ], + [ + 'reordered primary key', + 'CREATE TABLE snapshot_profile_keys (snapshotTableId INTEGER NOT NULL, snapshotUserId INTEGER NOT NULL, snapshotRowId INTEGER NOT NULL, PRIMARY KEY(snapshotUserId, snapshotTableId, snapshotRowId))' + ], + [ + 'extra unique constraint', + 'CREATE TABLE snapshot_profile_keys (snapshotTableId INTEGER NOT NULL, snapshotUserId INTEGER NOT NULL UNIQUE, snapshotRowId INTEGER NOT NULL, PRIMARY KEY(snapshotTableId, snapshotUserId, snapshotRowId))' + ], + [ + 'descending key', + 'CREATE TABLE snapshot_profile_keys (snapshotTableId INTEGER NOT NULL, snapshotUserId INTEGER NOT NULL, snapshotRowId INTEGER NOT NULL, PRIMARY KEY(snapshotTableId DESC, snapshotUserId, snapshotRowId))' + ], + [ + 'different collation', + 'CREATE TABLE snapshot_profile_keys (snapshotTableId INTEGER NOT NULL, snapshotUserId INTEGER NOT NULL, snapshotRowId INTEGER NOT NULL, PRIMARY KEY(snapshotTableId COLLATE NOCASE, snapshotUserId, snapshotRowId))' + ] +])('auxiliary %s schema cannot be adopted or deleted', async (_name, sql) => { + const k = await minimal() + await k.raw(sql) + await expect(addSnapshotProfileIndexes(k)).rejects.toThrow('table definition mismatch') + await expect(removeSnapshotProfileIndexes(k)).rejects.toThrow('table definition mismatch') + expect(await k('sqlite_master').where({ type: 'table', name: 'snapshot_profile_keys' }).first('sql')).toEqual({ sql }) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) +}) + +test.each([ + { afterRowId: -1 }, + { afterRowId: 1.5 }, + { afterRowId: 'invalid' }, + { afterRowId: Number.MAX_SAFE_INTEGER + 1 }, + { complete: 2 }, + { complete: 'invalid' } +])('malformed bootstrap progress %j refuses before source reads', async change => { + const k = await minimal() + await addSnapshotProfileIndexes(k) + await k('snapshot_profile_index_progress') + .where('snapshotTableId', 0) + .update({ complete: 0, ...change }) + const queries: string[] = [] + const listen = (query: { sql: string }): void => { + queries.push(query.sql) + } + k.on('query', listen) + try { + await expect(addSnapshotProfileIndexes(k)).rejects.toThrow('bootstrap position') + } finally { + k.off('query', listen) + } + expect(queries.some(sql => sql.startsWith('select `transactionId`, `userId` from `transactions`'))).toBe(false) + expect(await k('snapshot_profile_keys')).toEqual([]) +}) + +test.each([0, -1, 1.5, 'invalid', Number.MAX_SAFE_INTEGER + 1])( + 'source profile key %p refuses before a bootstrap checkpoint can be completed', + async userId => { + const k = await minimal() + await k('transactions').insert({ transactionId: 1, userId }) + await expect(addSnapshotProfileIndexes(k)).rejects.toThrow('source key') + expect(await k('snapshot_profile_keys')).toEqual([]) + expect(await k('snapshot_profile_index_progress').where('snapshotTableId', 0).first()).toEqual({ + snapshotTableId: 0, + afterRowId: 0, + complete: 0 + }) + } +) + +test('journaled missing key/progress tables refuse rather than presenting an empty wallet', async () => { + const k = await minimal() + await k.schema.createTable('knex_migrations', columns => { + columns.string('name') + }) + await k('knex_migrations').insert({ name: SNAPSHOT_PROFILE_INDEX_MIGRATION }) + await expect(readSnapshotProfileIndexState(k)).rejects.toThrow('migration is incomplete') + await addSnapshotProfileIndexes(k) + await k.schema.dropTable('snapshot_profile_index_progress') + await expect(readSnapshotProfileIndexState(k)).rejects.toThrow('migration is incomplete') +}) + +test('incomplete SQLite primary-index metadata refuses before any bootstrap or trigger is installed', async () => { + const k = await minimal() + let omitted = false + const omitIndexMetadata = (rows: unknown, query: { sql: string }): void => { + if (query.sql.startsWith('PRAGMA index_list(') && query.sql.includes('snapshot_profile_keys')) { + expect(Array.isArray(rows)).toBe(true) + ;(rows as unknown[]).splice(0) + omitted = true + } + } + k.on('query-response', omitIndexMetadata) + try { + await expect(addSnapshotProfileIndexes(k)).rejects.toThrow('table definition mismatch') + } finally { + k.off('query-response', omitIndexMetadata) + } + expect(omitted).toBe(true) + expect(await k.schema.hasTable('snapshot_profile_index_progress')).toBe(false) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index adb3590de..851fcaf16 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' -import { SNAPSHOT_ARCHIVE_GUARD_MIGRATION } from '../../schema/KnexMigrations' +import { SNAPSHOT_PROFILE_INDEX_MIGRATION } from '../../schema/KnexMigrations' import { decodeSyncTransfer } from '../../remoting/SyncTransfer' import * as Transfer from '../../remoting/SyncTransfer' import * as ArchiveSource from './KnexSnapshotArchiveSource' @@ -72,7 +72,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { onProgress: p => progress.push(p) }) - expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_ARCHIVE_GUARD_MIGRATION) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_PROFILE_INDEX_MIGRATION) expect(manifest.binding.sourceStorage.storageName).toBe('original source') expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) @@ -84,7 +84,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof sourceStorageIdentityKey: 'original-source', archiveId: manifest.archiveId, digest: manifest.digest, - sourceSchema: SNAPSHOT_ARCHIVE_GUARD_MIGRATION + sourceSchema: SNAPSHOT_PROFILE_INDEX_MIGRATION }) expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} @@ -153,7 +153,7 @@ test('source schema, primary history and closure stay pinned while an independen await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) - expect(source.sourceSchema).toBe(SNAPSHOT_ARCHIVE_GUARD_MIGRATION) + expect(source.sourceSchema).toBe(SNAPSHOT_PROFILE_INDEX_MIGRATION) expect(source.user.activeStorage).toBe(originalPrimary) await expect(source.validateClosure()).resolves.toBeUndefined() expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts index 4be5089cd..0d277b753 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts @@ -124,7 +124,7 @@ const references: readonly Reference[] = [ * constant marker is returned for an invalid relation; blobs and full ID maps * are never loaded. This does not parse or authenticate BRC-38/39 documents. */ -export async function assertKnexSnapshotArchiveClosure(k: Knex, userId: number): Promise { +export async function assertKnexSnapshotArchiveClosure(k: Knex, userId: number, profileIndexes = false): Promise { if (!Number.isSafeInteger(userId) || userId < 1) throw new WERR_INVALID_PARAMETER('userId', 'a positive safe ID') await runInSeries(references, async reference => { const column = `${reference.source}.${reference.field}` @@ -132,7 +132,7 @@ export async function assertKnexSnapshotArchiveClosure(k: Knex, userId: number): .select(k.raw('1')) .whereRaw('?? = ??', [`${reference.target}.${reference.key}`, column]) if (reference.profile) void target.where(`${reference.target}.userId`, userId) - const invalid = walletSnapshotSourceQuery(k, reference.table, userId) + const invalid = walletSnapshotSourceQuery(k, reference.table, userId, profileIndexes) .select(k.raw('1 AS invalid')) .whereNotExists(target) if (reference.optional === true) void invalid.whereNotNull(column) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index 254ec1582..cc9e74b32 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -1,3 +1,4 @@ +import { readSnapshotProfileIndexState } from '../../schema/snapshotProfileIndexMigration' import { Random, Utils } from '@bsv/sdk' import type { Knex } from 'knex' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' @@ -50,11 +51,18 @@ export async function openKnexSnapshotArchiveSource( } const view = await openView() try { - const header = await view.read(async trx => { + const { header, profileIndexes } = await view.read(async trx => { const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') - return { sourceStorage, user, sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) } + return { + header: { + sourceStorage, + user, + sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) + }, + profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations) + } }) const userId = header.user.userId const snapshotId = Utils.toHex(Random(32)) @@ -68,9 +76,9 @@ export async function openKnexSnapshotArchiveSource( }, closed: view.closed, close: view.close, - readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view), + readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view, profileIndexes), validateClosure: async () => { - await view.read(trx => assertKnexSnapshotArchiveClosure(storage.toDb(trx), userId)) + await view.read(trx => assertKnexSnapshotArchiveClosure(storage.toDb(trx), userId, profileIndexes)) } } } catch (error) { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index da296e5fd..105ca34fc 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -631,7 +631,7 @@ test('only acknowledged sequence positions are readable, even if an unacknowledg test('the auxiliary migration is registered after the durable sync schema', async () => { const migrations = new KnexMigrations('test', 'source', 'source', 1024) - expect(await migrations.getLatestMigration()).toBe('2026-10-01-002 add snapshot archive source guards') + expect(await migrations.getLatestMigration()).toBe('2026-10-01-003 add snapshot profile key indexes') }) test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts index a3f428f31..ecd40aa00 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts @@ -33,7 +33,7 @@ test.each([StorageClient, StorageMobile])( expect(storage.getSettings()).not.toHaveProperty('snapshotArchive') let transport = (await client.getSnapshotArchiveTransport(identityKey))! const offer = await transport.offer() - expect(offer.sourceSchema).toBe('2026-10-01-002 add snapshot archive source guards') + expect(offer.sourceSchema).toBe('2026-10-01-003 add snapshot profile key indexes') expect(Math.abs(offer.serverTime - Date.now())).toBeLessThan(5000) const fields = { version: 1 as const, diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs index cb42c856b..9e8dbf004 100644 --- a/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs @@ -1,49 +1,77 @@ // Disposable, loopback-only synthetic MySQL qualification. Never targets an // operator-supplied database, retains a volume, pulls an image, or builds one. -const { execFileSync } = require('node:child_process') +const { execFile } = require('node:child_process') const { randomBytes, randomUUID } = require('node:crypto') const { join } = require('node:path') const assert = require('node:assert/strict') -const executable = require('./snapshotArchiveDocker.cjs') +const { executable, context, image, validateContext, validateContainer } = require('./snapshotArchiveDocker.cjs') const { runInSeries } = require('../../out/src/utility/runInSeries.js') -const secret = randomBytes(32).toString('hex') -const fixtureEnvironment = { ...process.env, MYSQL_ROOT_PASSWORD: secret, MYSQL_PWD: secret } -const image = 'mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d' -const docker = (...args) => - execFileSync(executable, ['--context', 'desktop-linux', ...args], { - encoding: 'utf8', - env: fixtureEnvironment, - stdio: ['ignore', 'pipe', 'pipe'] - }).trim() +const execute = (file, args, options) => + new Promise((resolve, reject) => { + execFile( + file, + args, + { encoding: 'utf8', killSignal: 'SIGKILL', maxBuffer: 1048576, ...options }, + (error, stdout) => { + if (error) reject(error) + else resolve(stdout) + } + ) + }) + async function main() { - assert.equal(docker('context', 'show'), 'desktop-linux', 'This fixture requires the local Docker Desktop context') - docker('image', 'inspect', image) - const name = 'ts569-durable-' + randomUUID().slice(0, 8) - const id = docker( - 'run', - '--pull=never', - '--detach', - '--name', - name, - '--label', - 'network-ops.fixture=ts-stack-544-durable', - '--memory', - '1g', - '--cpus', - '2', - '--pids-limit', - '256', - '--tmpfs', - '/var/lib/mysql:rw,nosuid,nodev,size=512m', - '--env', - 'MYSQL_ROOT_PASSWORD', - '--env', - 'MYSQL_DATABASE=ts569_snapshot', - '--publish', - '127.0.0.1::3306', - image - ) + const secret = randomBytes(32).toString('hex') + const fixtureEnvironment = { ...process.env, MYSQL_ROOT_PASSWORD: secret, MYSQL_PWD: secret } + const cancellation = new AbortController() + const interrupt = () => cancellation.abort(new Error('Snapshot fixture cancelled')) + process.once('SIGINT', interrupt) + process.once('SIGTERM', interrupt) + const command = async (args, signal) => + ( + await execute(executable, ['--context', context, ...args], { + timeout: 15000, + env: fixtureEnvironment, + ...(signal === undefined ? {} : { signal }) + }) + ).trim() + const docker = (...args) => command(args, cancellation.signal) + const cleanup = (...args) => command(args) + const owner = randomUUID() + const name = 'ts569-durable-' + owner + let id + let creating = false + let failure try { + validateContext(JSON.parse(await docker('context', 'inspect', context))) + await docker('image', 'inspect', image) + creating = true + id = await docker( + 'run', + '--pull=never', + '--detach', + '--name', + name, + '--label', + 'network-ops.fixture=ts-stack-544-durable', + '--label', + 'network-ops.fixture-owner=' + owner, + '--memory', + '1g', + '--cpus', + '2', + '--pids-limit', + '256', + '--tmpfs', + '/var/lib/mysql:rw,nosuid,nodev,size=512m', + '--env', + 'MYSQL_ROOT_PASSWORD', + '--env', + 'MYSQL_DATABASE=ts569_snapshot', + '--publish', + '127.0.0.1::3306', + image + ) + validateContainer(JSON.parse(await docker('inspect', id))[0], { name, owner, id }) const deadline = Date.now() + 60000 let ready = false function* pendingReadiness() { @@ -52,7 +80,7 @@ async function main() { await runInSeries(pendingReadiness(), async () => { try { // TCP specifically excludes the entrypoint's temporary socket-only server. - docker( + await docker( 'exec', '--env', 'MYSQL_PWD', @@ -65,28 +93,56 @@ async function main() { ) ready = true } catch (error) { - if (Date.now() >= deadline) throw error + if (cancellation.signal.aborted || Date.now() >= deadline) throw error await new Promise(resolve => setTimeout(resolve, 500)) } }) - const result = execFileSync(process.execPath, [join(__dirname, 'snapshotArchiveMysql.cjs')], { - encoding: 'utf8', + const result = await execute(process.execPath, [join(__dirname, 'snapshotArchiveMysql.cjs')], { env: { ...process.env, TS_STACK_SNAPSHOT_CONTAINER: name, TS_STACK_SNAPSHOT_CONTAINER_ID: id, + TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, TS_STACK_SNAPSHOT_MYSQL_SECRET: secret }, - timeout: 60000, - stdio: ['ignore', 'pipe', 'pipe'] + signal: cancellation.signal, + timeout: 60000 }) process.stdout.write(result) - } finally { - docker('rm', '--force', id) - assert.equal(docker('ps', '-aq', '--filter', 'id=' + id), '') + } catch (error) { + failure = error + } + let cleanupFailure + if (creating) { + // A timed-out create may have succeeded before its reply was lost. Cleanup + // keeps its own deadline after cancellation and requires the complete claim. + try { + const owned = () => cleanup('ps', '-aq', '--filter', 'label=network-ops.fixture-owner=' + owner) + const pending = await owned() + if (pending !== '') { + const containers = JSON.parse(await cleanup('inspect', ...pending.split('\n'))) + assert.equal(containers.length, 1) + validateContainer(containers[0], { name, owner, id }) + await cleanup('rm', '--force', containers[0].Id) + } + assert.equal(await owned(), '') + } catch (error) { + cleanupFailure = error + } } + process.removeListener('SIGINT', interrupt) + process.removeListener('SIGTERM', interrupt) + if (cleanupFailure !== undefined) + throw new AggregateError( + failure === undefined ? [cleanupFailure] : [failure, cleanupFailure], + 'Snapshot fixture cleanup is unproved' + ) + if (failure !== undefined) throw failure + cancellation.signal.throwIfAborted() } -main().catch(error => { - console.error(error) - process.exitCode = 1 -}) +module.exports = main +if (require.main === module) + main().catch(error => { + console.error(error) + process.exitCode = 1 + }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs index 466b75f38..2678183ae 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs @@ -163,6 +163,8 @@ async function main() { await parent() const { qualifySQLiteGuardProcessLoss } = require('./snapshotArchiveGuardCrash.cjs') console.log(JSON.stringify({ ownerProcessLoss: await qualifySQLiteGuardProcessLoss() })) + const { qualifySQLiteProfileIndexProcessLoss } = require('./snapshotProfileIndexCrash.cjs') + console.log(JSON.stringify({ profileIndexProcessLoss: await qualifySQLiteProfileIndexProcessLoss() })) } } main().catch(error => { diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.cjs index 563b05067..b8cf6164f 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.cjs @@ -1,5 +1,18 @@ +const assert = require('node:assert/strict') const { existsSync } = require('node:fs') +// Hosted qualification is explicit and may use only the runner's local daemon. +const mode = process.env.TS_STACK_SNAPSHOT_HOSTED_MYSQL +if (mode !== undefined) { + assert.equal(mode, '1', 'Unsupported snapshot fixture Docker mode') + assert.equal(process.platform, 'linux', 'Hosted fixture requires Linux') + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Hosted fixture requires GitHub Actions') + assert.equal(process.env.DOCKER_HOST, undefined, 'An external Docker host is not supported') + assert.equal(process.env.DOCKER_CONTEXT, undefined, 'An external Docker context is not supported') +} +const context = mode === '1' ? 'default' : 'desktop-linux' +const image = 'mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d' + // Fixture executables use known installation paths rather than searching PATH. const executable = [ '/Applications/Docker.app/Contents/Resources/bin/docker', @@ -8,5 +21,47 @@ const executable = [ '/opt/homebrew/bin/docker' ].find(candidate => existsSync(candidate)) -if (executable === undefined) throw new Error('Install Docker Desktop at a supported local installation path') -module.exports = executable +if (executable === undefined) throw new Error('Install Docker at a supported local installation path') + +function validateContext(records) { + assert.equal(Array.isArray(records) && records.length, 1, 'Expected one local Docker context') + assert.equal(records[0].Name, context) + const host = records[0].Endpoints?.docker?.Host + if (mode === '1') assert.equal(host, 'unix:///var/run/docker.sock', 'Hosted fixture requires the local Unix socket') + else + assert.equal( + typeof host === 'string' && host.startsWith('unix:///'), + true, + 'Desktop fixture requires a local Unix socket' + ) +} + +function validateContainer(actual, expected) { + assert.match(expected.owner, /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/) + assert.equal(expected.name, 'ts569-durable-' + expected.owner) + assert.equal(actual.Name, '/' + expected.name) + assert.equal(actual.Config.Labels['network-ops.fixture'], 'ts-stack-544-durable') + assert.equal(actual.Config.Labels['network-ops.fixture-owner'], expected.owner) + assert.equal(actual.Config.Image, image) + if (expected.id !== undefined) assert.equal(actual.Id, expected.id) + assert.match(actual.Id, /^[a-f0-9]{64}$/) +} + +module.exports = { executable, context, image, validateContext, validateContainer } + +// The launcher never pulls. Provision this exact image only in an explicit +// hosted-runner step, before the disposable fixture acquires any resources. +if (require.main === module) { + assert.deepEqual(process.argv.slice(2), ['provision-hosted-image']) + assert.equal(mode, '1', 'Image provisioning requires explicit hosted mode') + const { execFileSync } = require('node:child_process') + validateContext( + JSON.parse( + execFileSync(executable, ['--context', context, 'context', 'inspect', context], { + encoding: 'utf8', + timeout: 15000 + }) + ) + ) + execFileSync(executable, ['--context', context, 'pull', image], { timeout: 240000, stdio: 'inherit' }) +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.test.ts b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.test.ts new file mode 100644 index 000000000..d819a191e --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveDocker.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { runInNewContext } from 'node:vm' + +interface FixtureDocker { + executable: string + context: string + image: string + validateContext: (records: unknown) => void + validateContainer: (actual: unknown, expected: unknown) => void +} +const source = readFileSync(join(__dirname, 'snapshotArchiveDocker.cjs'), 'utf8') +const desktop = '/Applications/Docker.app/Contents/Resources/bin/docker' +const hosted = { TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1', GITHUB_ACTIONS: 'true' } +function load(platform = 'darwin', env: Record = {}, available = desktop): FixtureDocker { + const module = { exports: {} } + runInNewContext(source, { + module, + process: { platform, env }, + require: (name: string) => { + if (name === 'node:fs') return { existsSync: (candidate: string) => candidate === available } + if (name === 'node:assert/strict') return assert + throw new Error('Unexpected fixture dependency') + } + }) + return module.exports as FixtureDocker +} +const endpoint = (name: string, host: string) => [{ Name: name, Endpoints: { docker: { Host: host } } }] + +test('local fixture retains its explicit Desktop context and known installation path', () => { + const docker = load() + expect(docker.context).toBe('desktop-linux') + expect(docker.executable).toBe(desktop) + expect(() => docker.validateContext(endpoint('desktop-linux', 'unix:///Users/synthetic/docker.sock'))).not.toThrow() + expect(() => docker.validateContext(endpoint('desktop-linux', 'tcp://remote.example:2375'))).toThrow() + expect(() => docker.validateContext(endpoint('default', 'unix:///var/run/docker.sock'))).toThrow() + expect(() => load('darwin', {}, '/untrusted/path/docker')).toThrow('supported local installation path') +}) + +test('explicit hosted Linux mode accepts only the runner default Unix socket', () => { + const docker = load('linux', hosted, '/usr/bin/docker') + expect(docker.context).toBe('default') + expect(docker.executable).toBe('/usr/bin/docker') + expect(() => docker.validateContext(endpoint('default', 'unix:///var/run/docker.sock'))).not.toThrow() + for (const records of [ + [], + {}, + endpoint('default', 'unix:///tmp/another.sock'), + endpoint('default', 'tcp://remote.example:2375'), + endpoint('desktop-linux', 'unix:///var/run/docker.sock'), + [...endpoint('default', 'unix:///var/run/docker.sock'), ...endpoint('default', 'unix:///var/run/docker.sock')] + ]) + expect(() => docker.validateContext(records)).toThrow() +}) + +test.each([ + ['darwin', hosted], + ['linux', { ...hosted, GITHUB_ACTIONS: 'false' }], + ['linux', { ...hosted, TS_STACK_SNAPSHOT_HOSTED_MYSQL: 'arbitrary' }], + ['linux', { ...hosted, DOCKER_HOST: 'tcp://remote.example:2375' }], + ['linux', { ...hosted, DOCKER_CONTEXT: 'another' }] +] as Array<[string, Record]>)( + 'invalid hosted fixture mode refuses before Docker I/O (%s, %j)', + (platform, env) => { + expect(() => load(platform, env, '/usr/bin/docker')).toThrow() + } +) + +test('cleanup requires the exact invocation claim, pinned image and container identity', () => { + const docker = load() + const owner = '00000000-0000-4000-8000-000000000001' + const expected = { name: 'ts569-durable-' + owner, owner, id: 'a'.repeat(64) } + const actual = { + Name: '/' + expected.name, + Id: expected.id, + Config: { + Image: docker.image, + Labels: { 'network-ops.fixture': 'ts-stack-544-durable', 'network-ops.fixture-owner': expected.owner } + } + } + expect(() => docker.validateContainer(actual, expected)).not.toThrow() + // A lost create reply may omit the ID, but never the complete ownership claim. + expect(() => docker.validateContainer(actual, { name: expected.name, owner: expected.owner })).not.toThrow() + for (const changed of [ + { ...actual, Name: '/another' }, + { ...actual, Id: 'b'.repeat(64) }, + { ...actual, Config: { ...actual.Config, Image: 'mysql:latest' } }, + { ...actual, Config: { ...actual.Config, Labels: { ...actual.Config.Labels, 'network-ops.fixture': 'another' } } }, + { + ...actual, + Config: { ...actual.Config, Labels: { ...actual.Config.Labels, 'network-ops.fixture-owner': 'another' } } + } + ]) + expect(() => docker.validateContainer(changed, expected)).toThrow() + expect(() => + docker.validateContainer({ ...actual, Id: '' }, { name: expected.name, owner: expected.owner }) + ).toThrow() +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index 3329a1e62..7a1726cb5 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -3,23 +3,18 @@ const { execFileSync } = require('node:child_process') const { knex } = require('knex') const { runInSeries } = require('../../out/src/utility/runInSeries.js') const { SnapshotArchiveCleanupPendingError } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveOwner.js') -const executable = require('./snapshotArchiveDocker.cjs') +const { executable, context, validateContext, validateContainer } = require('./snapshotArchiveDocker.cjs') const container = process.env.TS_STACK_SNAPSHOT_CONTAINER const expectedId = process.env.TS_STACK_SNAPSHOT_CONTAINER_ID +const owner = process.env.TS_STACK_SNAPSHOT_CONTAINER_OWNER const secret = process.env.TS_STACK_SNAPSHOT_MYSQL_SECRET -if (!container || !expectedId || !secret) throw new Error('Use the bounded local fixture launcher') -const actual = JSON.parse( - execFileSync(executable, ['--context', 'desktop-linux', 'inspect', container], { encoding: 'utf8' }) -)[0] -assert.equal(actual.Id, expectedId) -assert.equal(actual.Config.Labels['network-ops.fixture'], 'ts-stack-544-durable') -assert.equal(actual.Config.Image, 'mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d') -const port = Number( - execFileSync(executable, ['--context', 'desktop-linux', 'port', expectedId, '3306/tcp'], { encoding: 'utf8' }) - .trim() - .split(':') - .at(-1) -) +if (!container || !expectedId || !owner || !secret) throw new Error('Use the bounded fixture launcher') +const docker = (...args) => + execFileSync(executable, ['--context', context, ...args], { encoding: 'utf8', timeout: 15000 }) +validateContext(JSON.parse(docker('context', 'inspect', context))) +const actual = JSON.parse(docker('inspect', container))[0] +validateContainer(actual, { name: container, owner, id: expectedId }) +const port = Number(docker('port', expectedId, '3306/tcp').trim().split(':').at(-1)) const connection = { host: '127.0.0.1', port, @@ -150,7 +145,7 @@ async function captureFixture() { assert.equal(manifest.pages, 14) assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') assert.equal(manifest.binding.user.activeStorage, 'historical selection') - assert.equal(manifest.binding.sourceSchema, '2026-10-01-002 add snapshot archive source guards') + assert.equal(manifest.binding.sourceSchema, '2026-10-01-003 add snapshot profile key indexes') const store = new KnexSnapshotArchiveStore(writer.knex) const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) @@ -438,7 +433,7 @@ async function requestFixture(writer, reader) { sourceStorageIdentityKey: 'native-source', digest: ready.digest }) - assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-002 add snapshot archive source guards') + assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-003 add snapshot profile key indexes') const page = await replacement.read(identity, ready.archiveId, 8) const decoded = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[8])) assert.equal(decoded.rows[0].label, 'replacement') @@ -539,6 +534,10 @@ async function main() { assert.equal(await database.schema.hasTable('snapshot_archive_pages'), true) assert.equal(Number((await database('snapshot_archive_capacity').first()).archives), 0) const capture = await captureFixture() + const { qualifyMysqlProfileIndexProcessLoss } = require('./snapshotProfileIndexCrash.cjs') + const profileIndexProcessLoss = await qualifyMysqlProfileIndexProcessLoss(database, connection) + const { qualifyMysqlProfileIndexLocks } = require('./snapshotProfileIndexMysql.cjs') + const profileIndexLocks = await qualifyMysqlProfileIndexLocks(database, connection) console.log( JSON.stringify({ version, @@ -551,6 +550,8 @@ async function main() { expiredPartialUnreadable: true, profileReservationRace: true, idempotentPartialDdl: true, + profileIndexProcessLoss, + profileIndexLocks, capture }) ) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysqlLauncher.test.ts b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysqlLauncher.test.ts new file mode 100644 index 000000000..38fb22013 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysqlLauncher.test.ts @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { runInNewContext } from 'node:vm' + +const source = readFileSync(join(__dirname, 'runSnapshotArchiveMysql.cjs'), 'utf8') +const dockerSource = readFileSync(join(__dirname, 'snapshotArchiveDocker.cjs'), 'utf8') +const owner = '00000000-0000-4000-8000-000000000001' +const id = 'a'.repeat(64) +type Failure = 'none' | 'image' | 'create-reply' | 'child' | 'cleanup-identity' | 'cancel' +interface Call { + executable: string + args: string[] + options: Record +} +function fixture(failure: Failure) { + const helper = { exports: {} } + runInNewContext(dockerSource, { + module: helper, + process: { platform: 'darwin', env: {} }, + require: (name: string) => (name === 'node:assert/strict' ? assert : { existsSync: () => true }) + }) + const docker = helper.exports as { image: string } + const metadata = { + Id: id, + Name: '/ts569-durable-' + owner, + Config: { + Image: docker.image, + Labels: { 'network-ops.fixture': 'ts-stack-544-durable', 'network-ops.fixture-owner': owner } + } + } + const calls: Call[] = [] + const signals = new Map void>() + const error = new Error('synthetic ' + failure) + let exists = false + let childFinished = false + const perform = (executable: string, args: string[], options: Record) => { + calls.push({ executable, args, options }) + if (executable === '/synthetic/node') { + childFinished = true + if (failure === 'child') throw error + return 'synthetic native proof\n' + } + assert.deepEqual(Array.from(args.slice(0, 2)), ['--context', 'desktop-linux']) + switch (args[2]) { + case 'context': + return JSON.stringify([ + { Name: 'desktop-linux', Endpoints: { docker: { Host: 'unix:///synthetic/docker.sock' } } } + ]) + case 'image': + if (failure === 'image') throw error + return '[]' + case 'run': + exists = true + if (failure === 'create-reply') throw error + return id + case 'inspect': + return JSON.stringify([ + { ...metadata, Id: childFinished && failure === 'cleanup-identity' ? 'b'.repeat(64) : id } + ]) + case 'exec': + return 'mysqld is alive' + case 'ps': + return exists ? id : '' + case 'rm': + assert.equal(args[4], id) + exists = false + return id + default: + throw new Error('Unexpected Docker command') + } + } + const execFile = ( + executable: string, + args: string[], + options: Record, + callback: (error: unknown, stdout: string) => void + ) => { + if (executable === '/synthetic/node' && failure === 'cancel') { + calls.push({ executable, args, options }) + const signal = options.signal as AbortSignal + signal.addEventListener('abort', () => callback(error, ''), { once: true }) + queueMicrotask(() => signals.get('SIGTERM')?.()) + return + } + Promise.resolve() + .then(() => perform(executable, args, options)) + .then( + value => callback(null, value), + error => callback(error, '') + ) + } + const module = { exports: undefined as unknown } + const dependencies: Record = { + 'node:assert/strict': assert, + 'node:child_process': { execFile }, + 'node:crypto': { randomBytes: () => Buffer.alloc(32), randomUUID: () => owner }, + 'node:path': { join }, + './snapshotArchiveDocker.cjs': helper.exports, + '../../out/src/utility/runInSeries.js': { + runInSeries: async (items: Iterable, visit: (value: unknown) => Promise) => { + for (const item of items) await visit(item) + } + } + } + runInNewContext(source, { + module, + __dirname, + setTimeout, + AbortController, + process: { + env: {}, + execPath: '/synthetic/node', + stdout: { write: () => undefined }, + once: (name: string, listener: () => void) => signals.set(name, listener), + removeListener: (name: string) => signals.delete(name) + }, + require: (name: string) => { + assert.ok(Object.hasOwn(dependencies, name)) + return dependencies[name] + } + }) + return { run: module.exports as () => Promise, calls, error, exists: () => exists, signals } +} + +test('successful fixture retains bounds and proves exact-owner removal after its child finishes', async () => { + const f = fixture('none') + await expect(f.run()).resolves.toBeUndefined() + expect(f.exists()).toBe(false) + const creation = f.calls.find(call => call.args[2] === 'run')! + for (const value of [ + '--pull=never', + '127.0.0.1::3306', + '1g', + '2', + '256', + '/var/lib/mysql:rw,nosuid,nodev,size=512m', + 'network-ops.fixture-owner=' + owner + ]) { + expect(creation.args).toContain(value) + } + const child = f.calls.find(call => call.executable === '/synthetic/node')! + expect(child.options.timeout).toBe(60000) + expect(child.options.env).toMatchObject({ + TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, + TS_STACK_SNAPSHOT_CONTAINER_ID: id + }) + expect( + f.calls.filter(call => call.executable !== '/synthetic/node').every(call => call.options.timeout === 15000) + ).toBe(true) + expect(f.calls.filter(call => call.args[2] === 'rm')).toHaveLength(1) +}) + +test.each(['create-reply', 'child'] as const)( + 'a %s failure removes only the claimed container and preserves the failure', + async failure => { + const f = fixture(failure) + await expect(f.run()).rejects.toBe(f.error) + expect(f.exists()).toBe(false) + expect(f.calls.filter(call => call.args[2] === 'rm')).toHaveLength(1) + } +) + +test('image inspection failure allocates no container and does not attempt removal', async () => { + const f = fixture('image') + await expect(f.run()).rejects.toBe(f.error) + expect(f.exists()).toBe(false) + expect(f.calls.some(call => ['run', 'rm'].includes(call.args[2]))).toBe(false) +}) + +test('an unexpected cleanup identity refuses removal and reports unproved cleanup', async () => { + const f = fixture('cleanup-identity') + await expect(f.run()).rejects.toThrow('cleanup is unproved') + expect(f.exists()).toBe(true) + expect(f.calls.some(call => call.args[2] === 'rm')).toBe(false) +}) + +test('SIGTERM cancels owned work, awaits independently bounded cleanup and removes listeners', async () => { + const f = fixture('cancel') + await expect(f.run()).rejects.toBe(f.error) + expect(f.exists()).toBe(false) + expect(f.signals.size).toBe(0) + const child = f.calls.find(call => call.executable === '/synthetic/node')! + expect((child.options.signal as AbortSignal).aborted).toBe(true) + const removal = f.calls.find(call => call.args[2] === 'rm')! + expect(removal.options.signal).toBeUndefined() + expect(removal.options.timeout).toBe(15000) +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexCrash.cjs new file mode 100644 index 000000000..36a60d0af --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexCrash.cjs @@ -0,0 +1,238 @@ +// Synthetic process-loss qualification, invoked by the existing native fixtures. +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { randomUUID } = require('node:crypto') +const fs = require('node:fs/promises') +const { writeFileSync } = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { KnexMigrations } = require('../../out/src/storage/schema/KnexMigrations.js') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + readSnapshotProfileIndexState, + snapshotProfileTables, + SNAPSHOT_PROFILE_INDEX_MIGRATION +} = require('../../out/src/storage/schema/snapshotProfileIndexMigration.js') + +const phases = ['after-key-table', 'partial-triggers', 'before-cursor', 'after-cursor', 'after-commit'] +const migrationName = 'synthetic profile crash' +const migrationIdentity = 'synthetic-profile-crash' +const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } +const provider = options => + new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: knex(options) }) + +async function seed(options) { + const source = provider(options) + try { + if (options.client === 'better-sqlite3') await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate(migrationName, migrationIdentity) + await source.makeAvailable() + const migrationSource = new KnexMigrations('test', migrationName, migrationIdentity, 1024) + await source.knex.migrate.down({ + migrationSource, + name: SNAPSHOT_PROFILE_INDEX_MIGRATION, + disableTransactions: false + }) + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await runInSeries([0, 1, 2], async batch => { + await source.knex('tx_labels').insert( + Array.from({ length: 200 }, (_, index) => ({ + ...dates, + userId: user.userId, + label: `crash-label-${batch * 200 + index}`, + isDeleted: false + })) + ) + }) + await source.knex('tx_labels').where('txLabelId', 9).delete() + return { userId: user.userId, otherId: other.userId } + } finally { + await source.destroy() + } +} + +async function child(options, phase, marker) { + assert(phases.includes(phase)) + assert.equal(typeof process.send, 'function', 'Child requires its fixture parent') + const source = provider(options) + let cursorWritten = false + const cursor = query => + query.sql.startsWith('update `snapshot_profile_index_progress`') && + query.bindings[0] === 257 && + query.bindings.at(-1) === 3 + const park = event => { + writeFileSync(marker, JSON.stringify({ phase, event }), { mode: 0o600 }) + process.kill(process.pid, 'SIGKILL') + } + source.knex.on('query', query => { + if (phase === 'before-cursor' && cursor(query)) park('query') + if (phase === 'after-commit' && cursorWritten && query.sql.toLowerCase().startsWith('begin')) + park('next-transaction') + }) + source.knex.on('query-response', (_result, query) => { + const sql = query.sql.toLowerCase() + if (phase === 'after-key-table' && sql.startsWith('create table `snapshot_profile_keys`')) park('query-response') + if (phase === 'partial-triggers' && sql.startsWith('create trigger snapshot_profile_0_update ')) + park('query-response') + if (cursor(query)) { + cursorWritten = true + if (phase === 'after-cursor') park('query-response') + } + if (phase === 'after-commit' && cursorWritten && sql.startsWith('commit')) park('query-response') + }) + try { + await source.migrate(migrationName, migrationIdentity) + throw new Error('Expected migration boundary was not reached') + } finally { + await source.destroy() + } +} + +async function terminateAt(options, phase) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-profile-boundary-')) + const marker = path.join(directory, 'boundary.json') + const processHandle = spawn(process.execPath, [__filename, 'child'], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + let stderr = '' + processHandle.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-65536) + }) + const exited = new Promise((resolve, reject) => { + processHandle.once('exit', (code, signal) => resolve({ code, signal })) + processHandle.once('error', reject) + }) + const timer = setTimeout(() => processHandle.kill('SIGKILL'), 15000) + try { + processHandle.send({ options, phase, marker }) + const result = await exited + assert.equal(result.signal, 'SIGKILL', stderr) + const observed = JSON.parse(await fs.readFile(marker, 'utf8')) + assert.equal(observed.phase, phase) + return { phase, event: observed.event, signal: result.signal } + } finally { + clearTimeout(timer) + if (processHandle.exitCode === null && processHandle.signalCode === null) { + processHandle.kill('SIGKILL') + await exited + } + await fs.rm(directory, { recursive: true, force: true }) + } +} + +async function qualify(options, phase) { + const { userId, otherId } = await seed(options) + const outcome = await terminateAt(options, phase) + const source = provider(options) + const database = source.knex + try { + assert.equal(await readSnapshotProfileIndexState(database), false) + if (['before-cursor', 'after-cursor', 'after-commit'].includes(phase)) { + const committed = phase === 'after-commit' + const progress = await database('snapshot_profile_index_progress').where('snapshotTableId', 3).first() + assert.equal(progress.afterRowId, committed ? 257 : 0) + assert.equal( + Number( + (await database('snapshot_profile_keys').where('snapshotTableId', 3).count({ count: '*' }).first()).count + ), + committed ? 256 : 0 + ) + } + await database('tx_labels').where('txLabelId', 3).update({ userId: otherId }) + await database('tx_labels').insert({ + ...dates, + txLabelId: 9, + userId, + label: 'reinserted-behind-cursor', + isDeleted: false + }) + // A killed migrator leaves Knex's lock claimed. This is fixture-owned recovery; + // the verified child is gone and no other migrator can own this isolated store. + await database.migrate.forceFreeMigrationsLock() + await source.migrate(migrationName, migrationIdentity) + assert.equal(await readSnapshotProfileIndexState(database), true) + await runInSeries(snapshotProfileTables.entries(), async ([tableId, { table, key }]) => { + const expected = (await database(table).select('userId', key).orderBy(key)).map(row => ({ + snapshotUserId: row.userId, + snapshotRowId: row[key] + })) + assert.deepEqual( + await database('snapshot_profile_keys') + .where('snapshotTableId', tableId) + .select('snapshotUserId', 'snapshotRowId') + .orderBy('snapshotRowId'), + expected + ) + }) + assert.equal( + Number( + ( + await database('knex_migrations') + .where('name', SNAPSHOT_PROFILE_INDEX_MIGRATION) + .count({ count: '*' }) + .first() + ).count + ), + 1 + ) + return { ...outcome, journalPublishedAfterRecovery: true, independentProfileChangeAndLowIdInsert: true } + } finally { + await source.destroy() + } +} + +async function qualifySQLiteProfileIndexProcessLoss() { + const results = [] + await runInSeries(phases, async phase => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-profile-index-crash-')) + try { + results.push( + await qualify( + { + client: 'better-sqlite3', + connection: { filename: path.join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }, + phase + ) + ) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + }) + return results +} + +async function qualifyMysqlProfileIndexProcessLoss(control, connection) { + // The calling fixture has already verified its disposable, pinned container. + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const results = [] + await runInSeries(phases, async phase => { + const database = 'ts569_profile_' + randomUUID().replaceAll('-', '') + await control.raw('CREATE DATABASE ??', [database]) + try { + results.push( + await qualify({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }, phase) + ) + } finally { + await control.raw('DROP DATABASE ??', [database]) + } + }) + return results +} + +if (process.argv[2] === 'child') { + assert.equal(typeof process.send, 'function', 'Child execution requires its fixture parent') + process.once('message', ({ options, phase, marker }) => { + child(options, phase, marker).catch(error => { + console.error(error) + process.exitCode = 1 + process.disconnect() + }) + }) +} +module.exports = { qualifySQLiteProfileIndexProcessLoss, qualifyMysqlProfileIndexProcessLoss } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexMysql.cjs new file mode 100644 index 000000000..d35142706 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexMysql.cjs @@ -0,0 +1,152 @@ +// Invoked only after the existing launcher verifies its disposable MySQL fixture. +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { addSnapshotProfileIndexes } = require('../../out/src/storage/schema/snapshotProfileIndexMigration.js') + +async function waitFor(check) { + for (let attempt = 0; attempt < 1000; attempt++) { + if (await check()) return + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('Synthetic MySQL lock observation deadline') +} + +async function qualifyMysqlProfileIndexLocks(control, connection) { + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const database = 'ts569_profile_' + randomUUID().replaceAll('-', '') + await control.raw('CREATE DATABASE ??', [database]) + const open = () => knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: open() }) + const writer = open() + const observer = open() + const k = source.knex + let transaction + try { + await source.migrate('synthetic profile locks', 'synthetic-profile-locks') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await k('tx_labels').insert( + Array.from({ length: 20 }, (_, index) => ({ + userId: user.userId, + label: `lock-label-${index}`, + isDeleted: false, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01') + })) + ) + const reset = async () => { + await k('snapshot_profile_keys').where('snapshotTableId', 3).delete() + await k('snapshot_profile_index_progress').where('snapshotTableId', 3).update({ afterRowId: 0, complete: false }) + } + const verify = async () => { + const expected = (await k('tx_labels').select('userId', 'txLabelId').orderBy('txLabelId')).map(row => ({ + snapshotUserId: row.userId, + snapshotRowId: row.txLabelId + })) + assert.deepEqual( + await k('snapshot_profile_keys') + .where('snapshotTableId', 3) + .select('snapshotUserId', 'snapshotRowId') + .orderBy('snapshotRowId'), + expected + ) + } + const waiting = async () => { + const [rows] = await observer.raw( + "SELECT COUNT(*) AS pending FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID AND l.ENGINE=w.ENGINE WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='tx_labels'" + ) + return Number(rows[0].pending) > 0 + } + await reset() + transaction = await writer.transaction() + await transaction('tx_labels').where('txLabelId', 1).update({ userId: other.userId }) + let finished = false + const first = addSnapshotProfileIndexes(k).then( + () => { + finished = true + return { ok: true } + }, + error => ({ ok: false, error }) + ) + try { + await waitFor(waiting) + assert.equal(finished, false) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + const firstResult = await first + if (!firstResult.ok) throw firstResult.error + await verify() + + await reset() + let reached = false + let release + const gate = new Promise(resolve => { + release = resolve + }) + const prototype = Object.getPrototypeOf(k.client) + const original = prototype.query + const hadOwn = Object.hasOwn(prototype, 'query') + let intercepted = false + prototype.query = async function (conn, query) { + const sql = typeof query === 'string' ? query : query.sql + if (!intercepted && sql.startsWith('insert ignore into `snapshot_profile_keys`')) { + intercepted = true + reached = true + await gate + } + return await original.call(this, conn, query) + } + const second = addSnapshotProfileIndexes(k).then( + () => ({ ok: true }), + error => ({ ok: false, error }) + ) + let change + let secondResult + let changedResult + try { + await waitFor(async () => reached) + let writerDone = false + change = writer('tx_labels') + .where('txLabelId', 3) + .update({ userId: other.userId }) + .then( + () => { + writerDone = true + return { ok: true } + }, + error => ({ ok: false, error }) + ) + await waitFor(waiting) + assert.equal(writerDone, false) + } finally { + release() + if (hadOwn) prototype.query = original + else delete prototype.query + secondResult = await second + if (change !== undefined) changedResult = await change + } + if (!secondResult.ok) throw secondResult.error + if (changedResult !== undefined && !changedResult.ok) throw changedResult.error + await verify() + return { + bootstrapWaitsForCommittedOwner: true, + sourceLocksRetainedThroughKeyAndCursorCommit: true, + independentWriterMaintainsExactOwnerAfterCommit: true, + lockEvidence: 'performance_schema.data_lock_waits in the isolated fixture database' + } + } finally { + if (transaction !== undefined && !transaction.isCompleted()) await transaction.rollback() + await observer.destroy() + await writer.destroy() + await source.destroy() + await control.raw('DROP DATABASE ??', [database]) + } +} +module.exports = { qualifyMysqlProfileIndexLocks } diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 7e874cbd7..fdc4d0338 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -401,6 +401,45 @@ test('native snapshot process-loss proof uses the same-head build in exactly one assert.deepEqual(wallet.permissions, { contents: 'read' }) }) +test('native MySQL uses a bounded pinned fixture in the existing required wallet shard', async () => { + const { parse } = await import('yaml') + const wallet = parse(readFileSync(CI_PATH, 'utf8')).jobs['coverage-wallet'] + const provisionCommand = 'node test/storage/snapshotArchiveDocker.cjs provision-hosted-image' + const proofCommand = 'node test/storage/runSnapshotArchiveMysql.cjs' + const selected = [provisionCommand, proofCommand].map(command => { + const steps = wallet.steps.filter(step => step.run === command) + assert.equal(steps.length, 1) + const step = steps[0] + assert.equal(step.if, "matrix.id == 'shard-1'") + assert.equal(step['working-directory'], 'packages/wallet/wallet-toolbox') + assert.equal(step['timeout-minutes'], 5) + assert.deepEqual(step.env, { TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1' }) + assert.equal(step['continue-on-error'], undefined) + return wallet.steps.indexOf(step) + }) + const sqlite = wallet.steps.findIndex( + step => step.run === 'node test/storage/snapshotArchiveCrash.cjs' + ) + const coverage = wallet.steps.findIndex( + step => step.name === 'Generate wallet-toolbox coverage shard' + ) + assert.ok( + sqlite >= 0 && sqlite < selected[0] && selected[0] < selected[1] && selected[1] < coverage + ) + assert.deepEqual(wallet.strategy.matrix.include, [ + { id: 'shard-1', shard: 1 }, + { id: 'shard-2', shard: 2 }, + { id: 'shard-3', shard: 3 }, + { id: 'shard-4', shard: 4 }, + { id: 'sync-http-0', latency: 0 }, + { id: 'sync-http-1000', latency: 1000 } + ]) + assert.equal(wallet.needs, 'prepare') + assert.equal(wallet['timeout-minutes'], 40) + assert.deepEqual(wallet.permissions, { contents: 'read' }) + assert.equal(wallet['continue-on-error'], undefined) +}) + test('the mutation quality job accepts skipped execution only for explicitly empty scope', () => { const job = workflowJobBlocks(readFileSync(CI_PATH, 'utf8')).find( candidate => candidate.name === 'mutation-quality' diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 2ed675e72..47a0ce2f5 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -215,3 +215,14 @@ retryable after proved physical cleanup. Regression tests cover both opening and closing failures, shared recovery admission, shutdown drainage, immutable request bindings and exact cancellation outcomes. These checks do not replace the complete mutation campaign or exact-head hosted qualification. + +The auxiliary profile-index checkpoint adds separate `(table, user, row)` keys +for eight directly owned SQL tables. Source triggers preserve independent-writer +changes and a 256-row bootstrap resumes from atomic key/progress commits. The +standard schema indexes and legacy OFFSET order remain intact. Ordinary readers, +archive pages and closure checks select the complete migration in the same +retained view, preserving all thirteen table values and cursor representations. +Incomplete journaled state refuses new views. This advances indexed profile +selection only: the remaining relationship/global-table work, immutable +commit-order high-water positions, complete tombstone propagation, nonblocking +IndexedDB and the rest of the implementation program remain required. From 6814ed282e617a7b572899915648fe03cf3d47f2 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 04:22:15 -0700 Subject: [PATCH 070/127] Fix preinstall fixture checks and simplify snapshot migration validation --- .../schema/snapshotProfileIndexMigration.ts | 290 ++++++++++-------- .../archive/SnapshotArchiveGuardRegistry.ts | 7 +- .../storage/snapshotProfileIndexMysql.cjs | 13 +- scripts/ci-orchestration.test.mjs | 121 ++++---- 4 files changed, 235 insertions(+), 196 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts index d33c663d2..c9ae0a003 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts @@ -1,5 +1,6 @@ import type { Knex } from 'knex' import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { runInSeries } from '../../utility/runInSeries' export const SNAPSHOT_PROFILE_INDEX_MIGRATION = '2026-10-01-003 add snapshot profile key indexes' @@ -23,10 +24,89 @@ function isMySQL(k: Knex): boolean { } function normalized(sql: string): string { - return sql - .replace(/\s+/g, ' ') - .replace(/ IF NOT EXISTS /g, ' ') - .trim() + return sql.replaceAll(/\s+/g, ' ').replaceAll(' IF NOT EXISTS ', ' ').trim() +} + +async function validateMysqlTable(k: Knex, table: string, keys: boolean, names: string[]): Promise { + const primary = keys ? names : ['snapshotTableId'] + const [columns]: Array< + Array<{ name: string; type: string; nullable: string; defaultValue: unknown; extra: string }> + > = await k.raw( + 'SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION', + [table] + ) + const [indexes]: Array< + Array<{ name: string; columnName: string; nonUnique: number; direction: string; prefix: unknown }> + > = await k.raw( + 'SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX', + [table] + ) + const types = keys ? ['int', 'int unsigned', 'int unsigned'] : ['int', 'int unsigned', 'tinyint'] + return ( + Array.isArray(columns) && + columns.length === names.length && + columns.every( + (column, i) => + column.name === names[i] && + column.type.replaceAll(/\(\d+\)/g, '') === types[i] && + column.nullable === 'NO' && + column.defaultValue === null && + column.extra === '' + ) && + Array.isArray(indexes) && + indexes.every(index => + index.name === 'PRIMARY' ? index.direction === 'A' && index.prefix === null : Number(index.nonUnique) === 1 + ) && + JSON.stringify(indexes.filter(index => index.name === 'PRIMARY').map(index => index.columnName)) === + JSON.stringify(primary) + ) +} + +async function validateSqlitePrimaryIndex(k: Knex, name: string | undefined, names: string[]): Promise { + if (name === undefined) return false + const parts: Array<{ name: string; desc: number; coll: string; key: number }> = await k.raw( + 'PRAGMA index_xinfo(??)', + [name] + ) + const indexed = parts.filter(part => part.key === 1) + return ( + indexed.length === names.length && + indexed.every((part, i) => part.name === names[i] && part.desc === 0 && part.coll === 'BINARY') + ) +} + +async function validateSqliteTable(k: Knex, table: string, keys: boolean, names: string[]): Promise { + const columns: Array<{ + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + hidden: number + }> = await k.raw('PRAGMA table_xinfo(??)', [table]) + const indexes: Array<{ name: string; unique: number; origin: string; partial: number }> = await k.raw( + 'PRAGMA index_list(??)', + [table] + ) + const expectedPk = keys ? [1, 2, 3] : [1, 0, 0] + const nullable = keys ? [1, 1, 1] : [0, 1, 1] + const types = keys ? ['integer', 'integer', 'integer'] : ['integer', 'integer', 'boolean'] + const valid = + Array.isArray(columns) && + columns.length === names.length && + columns.every( + (column, i) => + column.name === names[i] && + column.type.toLowerCase() === types[i] && + column.notnull === nullable[i] && + column.dflt_value === null && + column.pk === expectedPk[i] && + column.hidden === 0 + ) && + Array.isArray(indexes) && + indexes.every(index => index.unique === 0 || (index.origin === 'pk' && index.partial === 0)) + if (!valid || !keys) return valid + return await validateSqlitePrimaryIndex(k, indexes.find(index => index.origin === 'pk')?.name, names) } async function validateTable(k: Knex, table: string): Promise { @@ -34,136 +114,75 @@ async function validateTable(k: Knex, table: string): Promise { const names = keys ? ['snapshotTableId', 'snapshotUserId', 'snapshotRowId'] : ['snapshotTableId', 'afterRowId', 'complete'] - const primary = keys ? names : ['snapshotTableId'] - let valid: boolean - if (isMySQL(k)) { - const [columns]: Array< - Array<{ name: string; type: string; nullable: string; defaultValue: unknown; extra: string }> - > = await k.raw( - 'SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION', - [table] - ) - const [indexes]: Array< - Array<{ name: string; columnName: string; nonUnique: number; direction: string; prefix: unknown }> - > = await k.raw( - 'SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX', - [table] - ) - const types = keys ? ['int', 'int unsigned', 'int unsigned'] : ['int', 'int unsigned', 'tinyint'] - valid = - Array.isArray(columns) && - columns.length === names.length && - columns.every( - (column, i) => - column.name === names[i] && - column.type.replace(/\(\d+\)/g, '') === types[i] && - column.nullable === 'NO' && - column.defaultValue === null && - column.extra === '' - ) && - Array.isArray(indexes) && - indexes.every(index => - index.name === 'PRIMARY' ? index.direction === 'A' && index.prefix === null : Number(index.nonUnique) === 1 - ) && - JSON.stringify(indexes.filter(index => index.name === 'PRIMARY').map(index => index.columnName)) === - JSON.stringify(primary) - } else { - const columns: Array<{ - name: string - type: string - notnull: number - dflt_value: unknown - pk: number - hidden: number - }> = await k.raw('PRAGMA table_xinfo(??)', [table]) - const indexes: Array<{ name: string; unique: number; origin: string; partial: number }> = await k.raw( - 'PRAGMA index_list(??)', - [table] - ) - const expectedPk = keys ? [1, 2, 3] : [1, 0, 0] - const nullable = keys ? [1, 1, 1] : [0, 1, 1] - const types = keys ? ['integer', 'integer', 'integer'] : ['integer', 'integer', 'boolean'] - valid = - Array.isArray(columns) && - columns.length === names.length && - columns.every( - (column, i) => - column.name === names[i] && - column.type.toLowerCase() === types[i] && - column.notnull === nullable[i] && - column.dflt_value === null && - column.pk === expectedPk[i] && - column.hidden === 0 - ) && - Array.isArray(indexes) && - indexes.every(index => index.unique === 0 || (index.origin === 'pk' && index.partial === 0)) - const pk = indexes.find(index => index.origin === 'pk') - if (valid && keys) { - if (pk === undefined) valid = false - else { - const parts: Array<{ name: string; desc: number; coll: string; key: number }> = await k.raw( - 'PRAGMA index_xinfo(??)', - [pk.name] - ) - const indexed = parts.filter(part => part.key === 1) - valid = - indexed.length === names.length && - indexed.every((part, i) => part.name === names[i] && part.desc === 0 && part.coll === 'BINARY') - } - } - } + const valid = isMySQL(k) + ? await validateMysqlTable(k, table, keys, names) + : await validateSqliteTable(k, table, keys, names) if (!valid) throw new WERR_INVALID_OPERATION('Snapshot profile table definition mismatch') } -async function installTrigger( - k: Knex, - table: string, - key: string, - tableId: number, - event: 'DELETE' | 'UPDATE' | 'INSERT', - create = true -): Promise { +type TriggerEvent = 'DELETE' | 'UPDATE' | 'INSERT' + +function triggerDefinition(mysql: boolean, table: string, key: string, tableId: number, event: TriggerEvent) { const name = `snapshot_profile_${tableId}_${event.toLowerCase()}` const remove = `DELETE FROM ${KEYS} WHERE snapshotTableId = ${tableId} AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.${key};` const add = `INSERT INTO ${KEYS} (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (${tableId}, NEW.userId, NEW.${key});` - const mysql = isMySQL(k) const changed = mysql ? `NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.${key} <=> NEW.${key})` : `OLD.userId IS NOT NEW.userId OR OLD.${key} IS NOT NEW.${key}` - const statements = event === 'DELETE' ? remove : event === 'INSERT' ? add : remove + ' ' + add + const statements = { DELETE: remove, INSERT: add, UPDATE: remove + ' ' + add }[event] const body = mysql && event === 'UPDATE' ? `BEGIN IF ${changed} THEN ${statements} END IF; END` : `BEGIN ${statements} END` - const qualifier = mysql ? ' FOR EACH ROW' : event === 'UPDATE' ? ` WHEN ${changed}` : '' - const sql = `CREATE TRIGGER ${name} AFTER ${event} ON ${table}${qualifier} ${body}` - if (mysql) { - const [rows]: Array> = await k.raw( - 'SELECT EVENT_MANIPULATION AS event, ACTION_TIMING AS timing, EVENT_OBJECT_TABLE AS tableName, ACTION_STATEMENT AS body FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME = ?', - [name] - ) - if (!Array.isArray(rows)) throw new WERR_INVALID_OPERATION('Invalid snapshot profile trigger metadata') - if (rows.length !== 0) { - const row = rows[0] - if ( - rows.length !== 1 || - row === undefined || - row.event !== event || - row.timing !== 'AFTER' || - row.tableName !== table || - normalized(row.body) !== normalized(body) - ) { - throw new WERR_INVALID_OPERATION('Snapshot profile trigger definition mismatch') - } - return - } - } else { - const row: { sql: string } | undefined = await k('sqlite_master').where({ type: 'trigger', name }).first('sql') - if (row !== undefined) { - if (normalized(row.sql) !== normalized(sql)) - throw new WERR_INVALID_OPERATION('Snapshot profile trigger definition mismatch') - return - } - } - if (create) await k.raw(sql) + let qualifier = '' + if (mysql) qualifier = ' FOR EACH ROW' + else if (event === 'UPDATE') qualifier = ` WHEN ${changed}` + return { name, body, sql: `CREATE TRIGGER ${name} AFTER ${event} ON ${table}${qualifier} ${body}` } +} + +async function mysqlTriggerExists( + k: Knex, + name: string, + table: string, + event: TriggerEvent, + body: string +): Promise { + const [rows]: Array> = await k.raw( + 'SELECT EVENT_MANIPULATION AS event, ACTION_TIMING AS timing, EVENT_OBJECT_TABLE AS tableName, ACTION_STATEMENT AS body FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME = ?', + [name] + ) + if (!Array.isArray(rows)) throw new WERR_INVALID_OPERATION('Invalid snapshot profile trigger metadata') + if (rows.length === 0) return false + const row = rows[0] + if ( + rows.length !== 1 || + row?.event !== event || + row.timing !== 'AFTER' || + row.tableName !== table || + normalized(row.body) !== normalized(body) + ) + throw new WERR_INVALID_OPERATION('Snapshot profile trigger definition mismatch') + return true +} + +async function sqliteTriggerExists(k: Knex, name: string, sql: string): Promise { + const row: { sql: string } | undefined = await k('sqlite_master').where({ type: 'trigger', name }).first('sql') + if (row === undefined) return false + if (normalized(row.sql) !== normalized(sql)) + throw new WERR_INVALID_OPERATION('Snapshot profile trigger definition mismatch') + return true +} + +async function installTrigger( + k: Knex, + table: string, + key: string, + tableId: number, + event: TriggerEvent, + create = true +): Promise { + const mysql = isMySQL(k) + const { name, body, sql } = triggerDefinition(mysql, table, key, tableId, event) + const exists = mysql ? await mysqlTriggerExists(k, name, table, event, body) : await sqliteTriggerExists(k, name, sql) + if (!exists && create) await k.raw(sql) } async function bootstrapTable(k: Knex, table: string, key: string, tableId: number): Promise { @@ -172,7 +191,10 @@ async function bootstrapTable(k: Knex, table: string, key: string, tableId: numb .onConflict('snapshotTableId') .ignore() let complete = false - while (!complete) { + function* unfinishedPages() { + while (!complete) yield undefined + } + await runInSeries(unfinishedPages(), async () => { complete = await k.transaction(async trx => { // SQLite must acquire its writer lock before reading a resumable position. // MySQL takes a current row lock without loading an older read-view value. @@ -218,7 +240,7 @@ async function bootstrapTable(k: Knex, table: string, key: string, tableId: numb .update({ afterRowId: keys.at(-1)?.snapshotRowId ?? state.afterRowId, complete: finished }) return finished }) - } + }) } /** Add auxiliary keys without altering any standard-table index or OFFSET plan. */ @@ -242,13 +264,13 @@ export async function addSnapshotProfileIndexes(k: Knex): Promise { }) } await validateTable(k, PROGRESS) - for (const [tableId, { table, key }] of snapshotProfileTables.entries()) { + await runInSeries(snapshotProfileTables.entries(), async ([tableId, { table, key }]) => { // Deletion must be observed before any partial installation can add keys. await installTrigger(k, table, key, tableId, 'DELETE') await installTrigger(k, table, key, tableId, 'UPDATE') await installTrigger(k, table, key, tableId, 'INSERT') await bootstrapTable(k, table, key, tableId) - } + }) } /** Call only after snapshot readers are drained; standard rows remain intact. */ @@ -257,15 +279,17 @@ export async function removeSnapshotProfileIndexes(k: Knex): Promise { throw new WERR_INVALID_OPERATION('Snapshot profile migration requires independent DDL and bootstrap transactions') if (await k.schema.hasTable(KEYS)) await validateTable(k, KEYS) if (await k.schema.hasTable(PROGRESS)) await validateTable(k, PROGRESS) - for (const [tableId, { table, key }] of snapshotProfileTables.entries()) { - for (const event of ['INSERT', 'UPDATE', 'DELETE'] as const) + await runInSeries(snapshotProfileTables.entries(), async ([tableId, { table, key }]) => { + await runInSeries(['INSERT', 'UPDATE', 'DELETE'] as const, async event => { await installTrigger(k, table, key, tableId, event, false) - } - for (const [tableId] of snapshotProfileTables.entries()) { + }) + }) + await runInSeries(snapshotProfileTables.entries(), async ([tableId]) => { // Stop every producer before removing the last deletion observer. - for (const event of ['insert', 'update', 'delete']) + await runInSeries(['insert', 'update', 'delete'], async event => { await k.raw(`DROP TRIGGER IF EXISTS snapshot_profile_${tableId}_${event}`) - } + }) + }) await k.schema.dropTableIfExists(PROGRESS) await k.schema.dropTableIfExists(KEYS) } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts index 5d4c3ed44..1bdf57509 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts @@ -40,8 +40,7 @@ export async function assertSnapshotArchiveGuardOwner(k: Knex, expected: Snapsho const actual = await ownerGuard(k, expected.owner) const request: RequestState | undefined = await k('snapshot_archive_requests').where(expected.owner).first() if ( - actual === undefined || - actual.slot !== expected.slot || + actual?.slot !== expected.slot || actual.bindingJson !== expected.bindingJson || request === undefined || request.released || @@ -77,7 +76,7 @@ export async function bindSnapshotArchiveOwnerGuard( await lockSnapshotArchiveCapacity(trx) await verifyBackend(trx) const current = await ownerGuard(trx, context.owner) - if (current === undefined || current.slot !== context.slot) unavailable() + if (current?.slot !== context.slot) unavailable() await assertSnapshotArchiveGuardOwner(trx, current) if (current.bindingJson !== null && current.bindingJson !== bindingJson) unavailable() await trx('snapshot_archive_owner_slots').where({ slot: current.slot }).update({ bindingJson }) @@ -114,7 +113,7 @@ export async function fenceSnapshotArchiveOwnerGuard( ): Promise { await lockSnapshotArchiveCapacity(k) const actual = await ownerGuard(k, context.owner) - if (actual === undefined || actual.slot !== context.slot || actual.bindingJson !== context.bindingJson) return false + if (actual?.slot !== context.slot || actual.bindingJson !== context.bindingJson) return false const request: RequestState | undefined = await k('snapshot_archive_requests').where(context.owner).first() if (request === undefined || request.released) unavailable() const expired = Number(request.expiresAt) <= (await snapshotArchiveDatabaseNow(k)) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexMysql.cjs index d35142706..57402fd26 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexMysql.cjs @@ -2,16 +2,21 @@ const assert = require('node:assert/strict') const { randomUUID } = require('node:crypto') const { knex } = require('knex') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') const { addSnapshotProfileIndexes } = require('../../out/src/storage/schema/snapshotProfileIndexMigration.js') async function waitFor(check) { - for (let attempt = 0; attempt < 1000; attempt++) { - if (await check()) return - await new Promise(resolve => setTimeout(resolve, 5)) + let complete = false + function* pendingAttempts() { + for (let attempt = 0; attempt < 1000 && !complete; attempt++) yield undefined } - throw new Error('Synthetic MySQL lock observation deadline') + await runInSeries(pendingAttempts(), async () => { + complete = await check() + if (!complete) await new Promise(resolve => setTimeout(resolve, 5)) + }) + if (!complete) throw new Error('Synthetic MySQL lock observation deadline') } async function qualifyMysqlProfileIndexLocks(control, connection) { diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index fdc4d0338..0c2093e9a 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -375,69 +375,80 @@ test('every HTTP latency scenario retains its own required coverage execution', assert.doesNotMatch(wallet, /continue-on-error|passWithNoTests/) }) -test('native snapshot process-loss proof uses the same-head build in exactly one required wallet shard', async () => { - const { parse } = await import('yaml') - const wallet = parse(readFileSync(CI_PATH, 'utf8')).jobs['coverage-wallet'] - const fixtures = wallet.steps.filter( - step => step.run === 'node test/storage/snapshotArchiveCrash.cjs' +function nativeWalletFixtureSteps() { + const wallet = workflowJobBlocks(readFileSync(CI_PATH, 'utf8')).find( + job => job.name === 'coverage-wallet' + ).source + const matches = [...wallet.matchAll(/^ - /gm)] + const steps = matches.map((match, index) => + wallet.slice(match.index, matches[index + 1]?.index ?? wallet.length) + ) + return { wallet, steps } +} + +function onlyFixtureStep(steps, command) { + const matches = steps.filter(step => step.split('\n').includes(` run: ${command}`)) + assert.equal(matches.length, 1) + const step = matches[0] + assert.match(step, /^ if: matrix.id == 'shard-1'$/m) + assert.match(step, /^ working-directory: packages\/wallet\/wallet-toolbox$/m) + assert.doesNotMatch(step, /continue-on-error/) + return steps.indexOf(step) +} + +function assertNativeWalletJob(wallet) { + assert.match(wallet, /^ needs: prepare$/m) + assert.match(wallet, /^ timeout-minutes: 40$/m) + assert.match(wallet, /^ permissions:\n contents: read\n strategy:/m) + assert.doesNotMatch(wallet, /continue-on-error/) + const matrix = / include:\n([\s\S]*?)\n steps:/.exec(wallet)?.[1] + assert.ok(matrix) + assert.deepEqual( + matrix.split('\n').map(line => line.trim()), + [ + '- { id: shard-1, shard: 1 }', + '- { id: shard-2, shard: 2 }', + '- { id: shard-3, shard: 3 }', + '- { id: shard-4, shard: 4 }', + '- { id: sync-http-0, latency: 0 }', + '- { id: sync-http-1000, latency: 1000 }' + ] ) - assert.equal(fixtures.length, 1) - const fixture = fixtures[0] - assert.equal(fixture.if, "matrix.id == 'shard-1'") - assert.equal(fixture['working-directory'], 'packages/wallet/wallet-toolbox') - assert.equal(fixture['continue-on-error'], undefined) - assert.equal(wallet['continue-on-error'], undefined) - assert.equal(wallet.strategy.matrix.include.filter(entry => entry.id === 'shard-1').length, 1) - const restored = wallet.steps.findIndex( - step => step.run === 'tar --extract --gzip --file .ci-artifacts/build-outputs.tar.gz' +} + +test('native snapshot process-loss proof uses the same-head build in exactly one required wallet shard', () => { + const { wallet, steps } = nativeWalletFixtureSteps() + const proof = onlyFixtureStep(steps, 'node test/storage/snapshotArchiveCrash.cjs') + const restored = steps.findIndex(step => + step.includes('run: tar --extract --gzip --file .ci-artifacts/build-outputs.tar.gz') ) - const proof = wallet.steps.indexOf(fixture) - const coverage = wallet.steps.findIndex( - step => step.name === 'Generate wallet-toolbox coverage shard' + const coverage = steps.findIndex(step => + step.includes('name: Generate wallet-toolbox coverage shard') ) assert.ok(restored >= 0 && restored < proof && proof < coverage) - assert.equal(wallet.needs, 'prepare') - assert.equal(wallet['timeout-minutes'], 40) - assert.deepEqual(wallet.permissions, { contents: 'read' }) + assertNativeWalletJob(wallet) }) -test('native MySQL uses a bounded pinned fixture in the existing required wallet shard', async () => { - const { parse } = await import('yaml') - const wallet = parse(readFileSync(CI_PATH, 'utf8')).jobs['coverage-wallet'] - const provisionCommand = 'node test/storage/snapshotArchiveDocker.cjs provision-hosted-image' - const proofCommand = 'node test/storage/runSnapshotArchiveMysql.cjs' - const selected = [provisionCommand, proofCommand].map(command => { - const steps = wallet.steps.filter(step => step.run === command) - assert.equal(steps.length, 1) - const step = steps[0] - assert.equal(step.if, "matrix.id == 'shard-1'") - assert.equal(step['working-directory'], 'packages/wallet/wallet-toolbox') - assert.equal(step['timeout-minutes'], 5) - assert.deepEqual(step.env, { TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1' }) - assert.equal(step['continue-on-error'], undefined) - return wallet.steps.indexOf(step) - }) - const sqlite = wallet.steps.findIndex( - step => step.run === 'node test/storage/snapshotArchiveCrash.cjs' - ) - const coverage = wallet.steps.findIndex( - step => step.name === 'Generate wallet-toolbox coverage shard' +test('native MySQL uses a bounded pinned fixture in the existing required wallet shard', () => { + const { wallet, steps } = nativeWalletFixtureSteps() + const provision = onlyFixtureStep( + steps, + 'node test/storage/snapshotArchiveDocker.cjs provision-hosted-image' ) - assert.ok( - sqlite >= 0 && sqlite < selected[0] && selected[0] < selected[1] && selected[1] < coverage + const proof = onlyFixtureStep(steps, 'node test/storage/runSnapshotArchiveMysql.cjs') + for (const index of [provision, proof]) { + assert.match(steps[index], /^ timeout-minutes: 5$/m) + assert.match( + steps[index], + /^ env:\n TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1'\n run:/m + ) + } + const sqlite = onlyFixtureStep(steps, 'node test/storage/snapshotArchiveCrash.cjs') + const coverage = steps.findIndex(step => + step.includes('name: Generate wallet-toolbox coverage shard') ) - assert.deepEqual(wallet.strategy.matrix.include, [ - { id: 'shard-1', shard: 1 }, - { id: 'shard-2', shard: 2 }, - { id: 'shard-3', shard: 3 }, - { id: 'shard-4', shard: 4 }, - { id: 'sync-http-0', latency: 0 }, - { id: 'sync-http-1000', latency: 1000 } - ]) - assert.equal(wallet.needs, 'prepare') - assert.equal(wallet['timeout-minutes'], 40) - assert.deepEqual(wallet.permissions, { contents: 'read' }) - assert.equal(wallet['continue-on-error'], undefined) + assert.ok(sqlite < provision && provision < proof && proof < coverage) + assertNativeWalletJob(wallet) }) test('the mutation quality job accepts skipped execution only for explicitly empty scope', () => { From 996cbb3927613efb5d447de539bdc4b5cbeda767 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 04:30:45 -0700 Subject: [PATCH 071/127] Include profile index migration in retained snapshot qualification --- governance/mutation-testing/targets.mjs | 1 + governance/test-quality/policy.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 9776e45b3..5d11218de 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -403,6 +403,7 @@ export function buildMutationTargets(repositoryRoot) { mutate: [ 'src/storage/snapshot/RetainedReadSnapshot.ts', 'src/storage/snapshot/KnexWalletReadSnapshot.ts', + 'src/storage/schema/snapshotProfileIndexMigration.ts', sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 72f23dc9a..0d833acf9 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -527,7 +527,8 @@ "Closing, cancellation and expiry reject later admission and discard late successful results.", "Capacity remains occupied until physical reads and transaction cleanup settle.", "Every completed lifecycle removes its expiry timer and rejects further reads.", - "Pinned keyset traversal returns every original owned row and tombstone exactly once under independent writes, within both page limits; cursor retries repeat the same page." + "Pinned keyset traversal returns every original owned row and tombstone exactly once under independent writes, within both page limits; cursor retries repeat the same page.", + "Auxiliary profile membership equals committed source ownership through generated insert, update, move, delete, rollback, repeated migration and rebuild schedules." ] }, { From dd270c601dd42673e42aa80721c351d19372a154 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 06:57:47 -0700 Subject: [PATCH 072/127] feat(wallet): add resumable numeric relation snapshot indexes --- docs/guides/wallet-sync-reliability.md | 44 +- docs/reference/package-api-migrations.md | 74 +-- docs/reference/test-quality-governance.md | 28 +- governance/mutation-testing/targets.mjs | 2 + governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 3 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 8 + packages/wallet/wallet-toolbox/README.md | 23 +- .../src/storage/schema/KnexMigrations.ts | 12 + .../schema/snapshotRelationIndexMigration.ts | 517 ++++++++++++++++++ .../ConcurrentSnapshotArchiveSource.test.ts | 23 +- .../ConcurrentSnapshotSyncSource.test.ts | 19 + .../KnexWalletReadSnapshot.mysql.test.ts | 32 +- .../snapshot/KnexWalletReadSnapshot.test.ts | 57 +- .../snapshot/KnexWalletReadSnapshot.ts | 68 ++- .../RetainedReadSnapshot.property.test.ts | 108 ++++ ...SnapshotProfileIndexes.integration.test.ts | 14 +- .../SnapshotProfileIndexes.migration.test.ts | 3 +- ...napshotRelationIndexes.integration.test.ts | 216 ++++++++ .../SnapshotRelationIndexes.mysql.test.ts | 373 +++++++++++++ .../snapshot/SnapshotRelationIndexes.test.ts | 256 +++++++++ .../KnexSnapshotArchiveCapture.test.ts | 8 +- .../archive/KnexSnapshotArchiveClosure.ts | 9 +- .../archive/KnexSnapshotArchiveSource.ts | 12 +- .../archive/KnexSnapshotArchiveStore.test.ts | 72 ++- .../archive/SnapshotArchiveHttp.test.ts | 2 +- .../test/storage/snapshotArchiveCrash.cjs | 2 + .../test/storage/snapshotArchiveMysql.cjs | 13 +- .../storage/snapshotRelationIndexCrash.cjs | 324 +++++++++++ .../storage/snapshotRelationIndexMysql.cjs | 251 +++++++++ .../storage/snapshotRelationIndexSeeks.cjs | 149 +++++ .../test/utils/snapshotRelationFixtures.ts | 85 +++ scripts/mutation-partitions.mjs | 22 + scripts/mutation-partitions.test.mjs | 114 +++- scripts/mutation-testing.mjs | 3 + scripts/mutation-testing.test.mjs | 73 +++ specs/wallet/sync-portability-program.md | 11 + 37 files changed, 2950 insertions(+), 84 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.integration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.mysql.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexCrash.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexMysql.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotRelationFixtures.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 36451cea1..b25e3b49d 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -217,7 +217,8 @@ mapping keys and field-name-first certificate keys, with each database's collation. This order differs from canonical BRC-38 array order. The auxiliary profile index below adds bounded profile range selection for eight direct tables without altering any standard-table index, legacy OFFSET order or cursor bytes. -Relationship/global-table query work, commit-order incremental high-water +The separate numeric relation migration extends that selection to label and tag +maps. Certificate-field/global-table query work, commit-order incremental high-water positions, IndexedDB retention and large-value streaming remain required by the active program. The local sync integration below consumes these pages. @@ -266,6 +267,47 @@ may read and write the unchanged standard schema while the forward migration's triggers remain installed; binary downgrade procedures still apply to the candidate's other migrations. +### Auxiliary numeric relation indexes (unpublished candidate) + +Migration `2026-10-01-004 add snapshot relation key indexes` adds +`snapshot_relation_keys` and `snapshot_relation_index_progress` for +`tx_labels_map` and `output_tags_map`. Keys preserve label/tag-first composite +cursor order. Each key records the independent left and right ownership bases: +a mapping belongs to either parent's profile, including tombstones and inconsistent +cross-profile mappings. Such inconsistencies remain visible to the existing +page/closure refusal; the index must not filter them out and silently export less +data. Parent moves, key updates and physical deletion remove only the affected +ownership basis. + +All removal observers across both relationship graphs are installed before any +producer. Bootstrap starts after the complete trigger set exists and commits at +most 256 mapping rows with its composite progress position. MySQL locks current +mapping rows and parent owners through commit. Trigger producers use current +locking reads even if their writer transaction has an older consistent read view. +SQLite uses its writer lock. Standard tables and their indexes are unchanged. +MySQL page queries explicitly select the auxiliary primary index: a maintenance +index can otherwise scan and sort an entire profile before applying the page limit. + +The same migration-journal, retained-view, interrupted-migrator recovery and +reader-drain requirements described above apply. The migration has +`transaction: false`; preserve partial owned objects and committed progress, then +retry only after excluding another migrator. MySQL index DDL can stop between +table and index creation. Compatible missing indexes resume; mismatched table, +index or trigger definitions refuse adoption or removal. Down validates all +objects before removing producers, observers and auxiliary tables. Drain readers +before down; standard rows, legacy OFFSET order and portable/cursor bytes remain +unchanged. These auxiliary tables are excluded from BRC-38. + +Repository fixtures compare all thirteen ordinary/archive tables and legacy +offsets before and after indexing, generate ownership/rekey schedules, and kill +the real migrator at seven DDL/bootstrap boundaries on SQLite and MySQL. The +native MySQL fixture observes independent locks under READ COMMITTED and +REPEATABLE READ and checks late-page row-read counts with interleaved profiles. +These are isolated synthetic fixtures, not deployed PXC or physical mobile +qualification. Certificate fields, proof requests/proofs, source commit ordering, +nonblocking IndexedDB, streaming and staged restore remain required. Reader +advertisement stays disabled and #569 remains open. + ## Durable local SQL sync and ordinary backup With the version-one migration applied, supported local SQL providers use these diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 1e7856357..964744568 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 921b4fd1f..0eba8c9fa 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -209,10 +209,30 @@ source scope, and every group runs the complete original selected test suite. Each group independently requires at least 90% detection and zero uncovered or invalid mutants; an aggregate score cannot hide a weak group. -Other targets retain their respective 45-minute PR and 20-minute standalone -limits. Four mutation workers, six parallel jobs, individual mutant/test -deadlines and all quality ratchets remain unchanged. A deadline cancellation is -not a completed report or a passing score. +The remote HTTP and service campaigns also exceeded their existing 90-minute +allowances on `6814ed282e617a7b572899915648fe03cf3d47f2`: the +[HTTP job](https://github.com/bsv-blockchain/ts-stack/actions/runs/36854954813/job/110346098383) +started 694 mutants after 550 passing dry-run tests, and the +[service job](https://github.com/bsv-blockchain/ts-stack/actions/runs/36854954813/job/110346098403) +started 1,357 mutants after 266 passing dry-run tests. Neither produced a complete +report. Their execution partitions keep every canonical specification exactly +once. HTTP places all server ranges together, all client ranges together, and +the whole protocol/RPC/transport sources in the protocol fallback. Service places +the whole controller in one part, the three whole guard modules in another, and +all other sources in the persistence fallback. Future canonical files join the +fallback automatically. Every execution part keeps the full original tests, +property suite, input dependencies and runner configuration. The existing +provenance and aggregate checks require all parts from the same source and +configuration, then evaluate the complete canonical mutant union; scores are +not averaged. These execution parts do not create new canonical targets. + +Both workflows use a 45-minute default and the same explicit 90-minute target +allowance list, including retained snapshots, snapshot sync, archive, remote +HTTP, remote reader and remote service. The execution split does not change +those limits, four mutation workers, six parallel jobs, individual mutant/test +deadlines, the 300-case fixed-seed property campaign or the 90% detection and +zero-uncovered/zero-invalid gates. A deadline cancellation is not a completed +report or a passing score. List and run targets locally: diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 5d11218de..81467792f 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -398,12 +398,14 @@ export function buildMutationTargets(repositoryRoot) { 'wallet-retained-snapshot': { packageDirectory: 'packages/wallet/wallet-toolbox', manifest: 'packages/wallet/wallet-toolbox/package.json', + additionalInputs: ['test/utils/snapshotRelationFixtures.ts'], propertyTest: 'packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts', mutate: [ 'src/storage/snapshot/RetainedReadSnapshot.ts', 'src/storage/snapshot/KnexWalletReadSnapshot.ts', 'src/storage/schema/snapshotProfileIndexMigration.ts', + 'src/storage/schema/snapshotRelationIndexMigration.ts', sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index d34372469..002e39fcc 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 0d833acf9..b9c80b392 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -528,7 +528,8 @@ "Capacity remains occupied until physical reads and transaction cleanup settle.", "Every completed lifecycle removes its expiry timer and rejects further reads.", "Pinned keyset traversal returns every original owned row and tombstone exactly once under independent writes, within both page limits; cursor retries repeat the same page.", - "Auxiliary profile membership equals committed source ownership through generated insert, update, move, delete, rollback, repeated migration and rebuild schedules." + "Auxiliary profile membership equals committed source ownership through generated insert, update, move, delete, rollback, repeated migration and rebuild schedules.", + "Numeric relationship membership equals the OR of current parent owners through generated map/parent rekeys, profile moves, tombstones, physical deletions, rollback and repeated migration; each ownership basis is retained independently." ] }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 5d575c7c5..69b8838d1 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,14 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add auxiliary numeric relationship indexes for label/tag maps without changing + standard indexes, composite cursors, legacy OFFSET order or BRC-38 bytes. + Preserve both parent ownership bases through moves, rekeys, tombstones and + deletion; retain inconsistent relationships for explicit closure refusal. + Resume bounded bootstrap after interrupted DDL/transactions and use the + matching auxiliary primary index for MySQL page seeks. The remaining indirect + tables and full sync/portability program are incomplete. + - Add an exact-claim source-owner fence and additive owner migration. Remote cancellation cannot release archive/request capacity or publish ready before the owning source and pool have closed. Append checks cancellation atomically; diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 71ed10eb9..20a9d2d53 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -51,6 +51,16 @@ readiness and the child proof have deadlines. Failure or cancellation drains owned work and attempts exact-owner cleanup before reporting its outcome; unproved cleanup fails qualification. Other wallet shards do not start MySQL. +Both native entry points include interrupted auxiliary profile and numeric +relation migrations through the real migrator. The numeric relation fixture +terminates seven migration boundaries, repairs the abandoned migration lock and +compares recovered membership with an independent source-table oracle. The MySQL +entry point also observes relation writer/reader lock ordering under both +supported isolation levels and checks late composite pages against 8,192 +interleaved map rows per relation. It requires bounded handler reads and the +auxiliary primary-key range plan. These synthetic fixture results qualify the +tested engine/configuration; deployed PXC remains a separate acceptance gate. + SQL providers also expose `supportsRetainedReadSnapshot` / `openReadSnapshot` for a local view held across idle reads, with one view per provider, one read at a time, and bounded lifetime/cancellation. Await `closed`/`close()` for physical @@ -99,6 +109,11 @@ OFFSET ordering while adding indexed snapshot selection for eight direct tables. Its triggers track independent writers; bounded bootstrap batches survive restart, and readers enable the index only from a complete migration in their retained view. See the [migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-profile-indexes-unpublished-candidate). +The separate numeric relation migration indexes label/tag maps while retaining +both parent ownership bases, tombstones and cross-profile inconsistency checks. +It preserves composite cursor and legacy OFFSET order, resumes bounded bootstrap +after interruption, and pins MySQL paging to the auxiliary primary index. See its +[migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-numeric-relation-indexes-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results @@ -125,13 +140,13 @@ Timing compares successive candidates, not a controlled comparison against upstr ### SQLite migration recovery SQLite migration handling introduced in 2.13.2 runs transactional migration DDL -and the migration journal update together. The unpublished profile-index -migration is an explicit resumable exception: auxiliary keys and progress commit +and the migration journal update together. The unpublished profile-index and +numeric relation migrations are explicit resumable exceptions: auxiliary keys and progress commit in bounded batches before its final migration journal entry. Foreign-key enforcement is disabled before the migration transaction for table rebuilds and restored after success or failure. Failed transactional migrations can be retried after reopening the database -without partial schema objects from that attempt. The resumable profile-index -migration instead retains its verified auxiliary objects and committed progress; +without partial schema objects from that attempt. These resumable index +migrations instead retain their verified auxiliary objects and committed progress; see its linked recovery contract before retrying an interrupted migrator. MySQL's existing transaction configuration is unchanged. diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index fd25f5005..bdcf00e81 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,8 @@ +import { + addSnapshotRelationIndexes, + removeSnapshotRelationIndexes, + SNAPSHOT_RELATION_INDEX_MIGRATION +} from './snapshotRelationIndexMigration' import { addSnapshotProfileIndexes, removeSnapshotProfileIndexes, @@ -37,6 +42,7 @@ import { LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS } from '../methods/managedChangePolicy' +export { SNAPSHOT_RELATION_INDEX_MIGRATION } from './snapshotRelationIndexMigration' export { SNAPSHOT_PROFILE_INDEX_MIGRATION } from './snapshotProfileIndexMigration' export { SNAPSHOT_ARCHIVE_OWNER_MIGRATION } from './snapshotArchiveOwnerMigration' export { SNAPSHOT_ARCHIVE_GUARD_MIGRATION } from './snapshotArchiveGuardMigration' @@ -128,6 +134,12 @@ export class KnexMigrations implements MigrationSource { // DDL may commit independently on MySQL. Bootstrap pages retain their own // durable positions on both backends and resume before journal publication. + migrations[SNAPSHOT_RELATION_INDEX_MIGRATION] = { + config: { transaction: false }, + up: addSnapshotRelationIndexes, + down: removeSnapshotRelationIndexes + } + migrations[SNAPSHOT_PROFILE_INDEX_MIGRATION] = { config: { transaction: false }, up: addSnapshotProfileIndexes, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts new file mode 100644 index 000000000..a288184f3 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts @@ -0,0 +1,517 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { runInSeries } from '../../utility/runInSeries' + +export const SNAPSHOT_RELATION_INDEX_MIGRATION = '2026-10-01-004 add snapshot relation key indexes' + +export const snapshotNumericRelations = [ + { table: 'tx_labels_map', left: 'tx_labels', leftKey: 'txLabelId', right: 'transactions', rightKey: 'transactionId' }, + { table: 'output_tags_map', left: 'output_tags', leftKey: 'outputTagId', right: 'outputs', rightKey: 'outputId' } +] as const + +type Relation = (typeof snapshotNumericRelations)[number] +type Side = 'left' | 'right' +type Event = 'INSERT' | 'UPDATE' | 'DELETE' +interface Trigger { + name: string + table: string + timing: 'BEFORE' | 'AFTER' + event: Event + body: string + sql: string +} + +const KEYS = 'snapshot_relation_keys' +const PROGRESS = 'snapshot_relation_index_progress' +const PAGE_ROWS = 256 +const keyColumns = ['snapshotTableId', 'snapshotUserId', 'snapshotLeftId', 'snapshotRightId'] +const indexes = [ + { + name: 'snapshot_relation_right', + columns: ['snapshotTableId', 'snapshotUserId', 'snapshotRightId', 'snapshotLeftId'] + }, + { name: 'snapshot_relation_map', columns: ['snapshotTableId', 'snapshotLeftId', 'snapshotRightId', 'snapshotUserId'] } +] + +function mysql(k: Knex): boolean { + return String(k.client.config.client).includes('mysql') +} + +function normalized(sql: string): string { + return sql.replaceAll(/\s+/g, ' ').trim() +} + +function sideInfo(relation: Relation, side: Side) { + return side === 'left' + ? { table: relation.left, key: relation.leftKey, index: 'snapshotLeftId', bit: 1 } + : { table: relation.right, key: relation.rightKey, index: 'snapshotRightId', bit: 2 } +} + +function insertMembership(isMysql: boolean, select: string, bit: number): string { + const merge = isMysql + ? ` ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | ${bit}` + : ` ON CONFLICT(${keyColumns.join(', ')}) DO UPDATE SET snapshotMembership = snapshotMembership | ${bit}` + return `INSERT INTO ${KEYS} (${keyColumns.join(', ')}, snapshotMembership) ${select}${merge};` +} + +function addMapSide(isMysql: boolean, relation: Relation, tableId: number, side: Side): string { + const { table, key, bit } = sideInfo(relation, side) + // An explicit locking read is required even under READ COMMITTED. The parent + // owner must stay current until this map transaction publishes its keys. + const lock = isMysql ? ' FOR SHARE' : '' + return insertMembership( + isMysql, + `SELECT ${tableId}, userId, NEW.${relation.leftKey}, NEW.${relation.rightKey}, ${bit} FROM ${table} WHERE ${key} = NEW.${key}${lock}`, + bit + ) +} + +function removeMap(relation: Relation, tableId: number): string { + return `DELETE FROM ${KEYS} WHERE snapshotTableId = ${tableId} AND snapshotLeftId = OLD.${relation.leftKey} AND snapshotRightId = OLD.${relation.rightKey};` +} + +function removeParent(relation: Relation, tableId: number, side: Side): string { + const { key, index, bit } = sideInfo(relation, side) + const where = `snapshotTableId = ${tableId} AND snapshotUserId = OLD.userId AND ${index} = OLD.${key}` + return `UPDATE ${KEYS} SET snapshotMembership = snapshotMembership & ${3 ^ bit} WHERE ${where}; DELETE FROM ${KEYS} WHERE ${where} AND snapshotMembership = 0;` +} + +function addParent(isMysql: boolean, relation: Relation, tableId: number, side: Side): string { + const { key, bit } = sideInfo(relation, side) + const lock = isMysql ? ' FOR SHARE' : '' + const select = `SELECT ${tableId}, NEW.userId, ${relation.leftKey}, ${relation.rightKey}, ${bit} FROM ${relation.table} WHERE ${key} = NEW.${key} ORDER BY ${relation.leftKey}, ${relation.rightKey}${lock}` + return insertMembership(isMysql, select, bit) +} + +function trigger( + isMysql: boolean, + name: string, + table: string, + timing: Trigger['timing'], + event: Event, + statements: string, + changed?: string +): Trigger { + const body = + isMysql && changed !== undefined ? `BEGIN IF ${changed} THEN ${statements} END IF; END` : `BEGIN ${statements} END` + let qualifier = '' + if (isMysql) qualifier = ' FOR EACH ROW' + else if (changed !== undefined) qualifier = ` WHEN ${changed}` + return { + name, + table, + timing, + event, + body, + sql: `CREATE TRIGGER ${name} ${timing} ${event} ON ${table}${qualifier} ${body}` + } +} + +function triggers(isMysql: boolean): { observers: Trigger[]; producers: Trigger[] } { + const observers: Trigger[] = [] + const producers: Trigger[] = [] + const different = (key: string): string => + isMysql ? `NOT (OLD.${key} <=> NEW.${key})` : `OLD.${key} IS NOT NEW.${key}` + for (const [tableId, relation] of snapshotNumericRelations.entries()) { + const prefix = `snapshot_relation_${tableId}` + const changed = `${different(relation.leftKey)} OR ${different(relation.rightKey)}` + const remove = removeMap(relation, tableId) + const add = addMapSide(isMysql, relation, tableId, 'left') + ' ' + addMapSide(isMysql, relation, tableId, 'right') + observers.push(trigger(isMysql, `${prefix}_map_delete`, relation.table, 'AFTER', 'DELETE', remove)) + observers.push(trigger(isMysql, `${prefix}_map_before_update`, relation.table, 'BEFORE', 'UPDATE', remove, changed)) + producers.push(trigger(isMysql, `${prefix}_map_insert`, relation.table, 'AFTER', 'INSERT', add)) + producers.push(trigger(isMysql, `${prefix}_map_after_update`, relation.table, 'AFTER', 'UPDATE', add, changed)) + for (const side of ['left', 'right'] as const) { + const { table, key } = sideInfo(relation, side) + const ownerChanged = `${different(key)} OR ${different('userId')}` + const subtract = removeParent(relation, tableId, side) + const append = addParent(isMysql, relation, tableId, side) + observers.push(trigger(isMysql, `${prefix}_${side}_delete`, table, 'AFTER', 'DELETE', subtract)) + observers.push( + trigger(isMysql, `${prefix}_${side}_before_update`, table, 'BEFORE', 'UPDATE', subtract, ownerChanged) + ) + producers.push(trigger(isMysql, `${prefix}_${side}_insert`, table, 'AFTER', 'INSERT', append)) + producers.push(trigger(isMysql, `${prefix}_${side}_after_update`, table, 'AFTER', 'UPDATE', append, ownerChanged)) + } + } + return { observers, producers } +} + +async function validateTrigger(k: Knex, expected: Trigger): Promise { + if (!mysql(k)) { + const row: { sql: string } | undefined = await k('sqlite_master') + .where({ type: 'trigger', name: expected.name }) + .first('sql') + if (row === undefined) return false + if (normalized(row.sql) !== normalized(expected.sql)) + throw new WERR_INVALID_OPERATION('Snapshot relation trigger definition mismatch') + return true + } + const [rows]: Array> = await k.raw( + 'SELECT EVENT_MANIPULATION AS event, ACTION_TIMING AS timing, EVENT_OBJECT_TABLE AS tableName, ACTION_STATEMENT AS body FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME = ?', + [expected.name] + ) + if (!Array.isArray(rows)) throw new WERR_INVALID_OPERATION('Invalid snapshot relation trigger metadata') + if (rows.length === 0) return false + const row = rows[0] + if ( + rows.length !== 1 || + row?.event !== expected.event || + row.timing !== expected.timing || + row.tableName !== expected.table || + normalized(row.body) !== normalized(expected.body) + ) { + throw new WERR_INVALID_OPERATION('Snapshot relation trigger definition mismatch') + } + return true +} + +interface Column { + name: string + type: 'integer' | 'boolean' + unsigned?: boolean + primary: number +} + +function tableColumns(keys: boolean): Column[] { + if (keys) + return [ + { name: 'snapshotTableId', type: 'integer', primary: 1 }, + { name: 'snapshotUserId', type: 'integer', unsigned: true, primary: 2 }, + { name: 'snapshotLeftId', type: 'integer', unsigned: true, primary: 3 }, + { name: 'snapshotRightId', type: 'integer', unsigned: true, primary: 4 }, + { name: 'snapshotMembership', type: 'integer', unsigned: true, primary: 0 } + ] + return [ + { name: 'snapshotTableId', type: 'integer', primary: 1 }, + { name: 'afterLeftId', type: 'integer', unsigned: true, primary: 0 }, + { name: 'afterRightId', type: 'integer', unsigned: true, primary: 0 }, + { name: 'complete', type: 'boolean', primary: 0 } + ] +} + +async function sqliteIndex(k: Knex, name: string, columns: string[]): Promise { + const rows: Array<{ name: string; desc: number; coll: string; key: number }> = await k.raw('PRAGMA index_xinfo(??)', [ + name + ]) + const parts = rows.filter(row => row.key === 1) + return ( + parts.length === columns.length && + parts.every((row, i) => row.name === columns[i] && row.desc === 0 && row.coll === 'BINARY') + ) +} + +async function sqliteTable(k: Knex, table: string, keys: boolean, secondary: boolean): Promise { + const expected = tableColumns(keys) + const columns: Array<{ + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + hidden: number + }> = await k.raw('PRAGMA table_xinfo(??)', [table]) + if ( + !Array.isArray(columns) || + columns.length !== expected.length || + columns.some((column, i) => { + const field = expected[i] + return ( + column.name !== field.name || + column.type.toLowerCase() !== field.type || + column.notnull !== (!keys && i === 0 ? 0 : 1) || + column.dflt_value !== null || + column.pk !== field.primary || + column.hidden !== 0 + ) + }) + ) + return false + const existing: Array<{ name: string; unique: number; origin: string; partial: number }> = await k.raw( + 'PRAGMA index_list(??)', + [table] + ) + if (existing.some(index => index.unique !== 0 && (index.origin !== 'pk' || index.partial !== 0))) return false + if (!keys) return true + const primary = existing.find(index => index.origin === 'pk') + if (primary === undefined || !(await sqliteIndex(k, primary.name, keyColumns))) return false + if (!secondary) return true + for (const expectedIndex of indexes) { + const found = existing.find(index => index.name === expectedIndex.name) + if ( + found === undefined || + found.unique !== 0 || + found.partial !== 0 || + !(await sqliteIndex(k, found.name, expectedIndex.columns)) + ) + return false + } + return true +} + +async function mysqlTable(k: Knex, table: string, keys: boolean, secondary: boolean): Promise { + const expected = tableColumns(keys) + const [columns]: Array< + Array<{ name: string; type: string; nullable: string; defaultValue: unknown; extra: string }> + > = await k.raw( + 'SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION', + [table] + ) + if ( + !Array.isArray(columns) || + columns.length !== expected.length || + columns.some((column, i) => { + const field = expected[i] + const type = field.type === 'boolean' ? 'tinyint' : 'int' + (field.unsigned === true ? ' unsigned' : '') + return ( + column.name !== field.name || + column.type.replaceAll(/\(\d+\)/g, '') !== type || + column.nullable !== 'NO' || + column.defaultValue !== null || + column.extra !== '' + ) + }) + ) + return false + const [parts]: Array< + Array<{ name: string; columnName: string; nonUnique: number; direction: string; prefix: unknown }> + > = await k.raw( + 'SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX', + [table] + ) + if (!Array.isArray(parts) || parts.some(index => index.name !== 'PRIMARY' && Number(index.nonUnique) !== 1)) + return false + const required = [ + { name: 'PRIMARY', columns: keys ? keyColumns : ['snapshotTableId'] }, + ...(keys && secondary ? indexes : []) + ] + return required.every(index => { + const found = parts.filter(part => part.name === index.name) + return ( + found.length === index.columns.length && + found.every((part, i) => part.columnName === index.columns[i] && part.direction === 'A' && part.prefix === null) + ) + }) +} + +async function validateTable(k: Knex, table: string, secondary = true): Promise { + const valid = mysql(k) + ? await mysqlTable(k, table, table === KEYS, secondary) + : await sqliteTable(k, table, table === KEYS, secondary) + if (!valid) throw new WERR_INVALID_OPERATION('Snapshot relation table definition mismatch') +} + +async function ensureTables(k: Knex): Promise { + if (!(await k.schema.hasTable(KEYS))) { + await k.schema.createTable(KEYS, t => { + t.integer('snapshotTableId').notNullable() + t.integer('snapshotUserId').unsigned().notNullable() + t.integer('snapshotLeftId').unsigned().notNullable() + t.integer('snapshotRightId').unsigned().notNullable() + t.integer('snapshotMembership').unsigned().notNullable() + t.primary(keyColumns) + }) + } + // MySQL DDL commits independently. Resume an interrupted creation between + // the table and either auxiliary index without trusting a conflicting index. + await validateTable(k, KEYS, false) + await runInSeries(indexes, async index => { + const exists = mysql(k) + ? ( + await k.raw( + 'SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1', + [KEYS, index.name] + ) + )[0].length !== 0 + : (await k('sqlite_master').where({ type: 'index', name: index.name }).first('name')) !== undefined + if (!exists) + await k.schema.alterTable(KEYS, t => { + t.index(index.columns, index.name) + }) + }) + await validateTable(k, KEYS) + if (!(await k.schema.hasTable(PROGRESS))) { + await k.schema.createTable(PROGRESS, t => { + t.integer('snapshotTableId').primary() + t.integer('afterLeftId').unsigned().notNullable() + t.integer('afterRightId').unsigned().notNullable() + t.boolean('complete').notNullable() + }) + } + await validateTable(k, PROGRESS) +} + +interface Position { + afterLeftId: number + afterRightId: number + complete: boolean | number +} + +function validPosition(state: Position | undefined): state is Position { + return ( + state !== undefined && + Number.isSafeInteger(state.afterLeftId) && + state.afterLeftId >= 0 && + Number.isSafeInteger(state.afterRightId) && + state.afterRightId >= 0 && + [false, true, 0, 1].includes(state.complete) + ) +} + +function positive(value: number | undefined): number { + if (value === undefined || !Number.isSafeInteger(value) || value < 1) + throw new WERR_INVALID_OPERATION('Invalid snapshot relation source key') + return value +} + +async function bootstrapPage(k: Knex, relation: Relation, tableId: number): Promise { + return await k.transaction(async trx => { + if (!mysql(k)) + await trx(PROGRESS) + .where('snapshotTableId', tableId) + .update({ afterLeftId: trx.ref('afterLeftId') }) + const progress = trx(PROGRESS).where('snapshotTableId', tableId) + if (mysql(k)) void progress.forUpdate() + const state: Position | undefined = await progress.first() + if (!validPosition(state)) throw new WERR_INVALID_OPERATION('Invalid snapshot relation bootstrap position') + if (state.complete === true || state.complete === 1) return true + const source = trx(relation.table) + .select(relation.leftKey, relation.rightKey) + .orderBy([relation.leftKey, relation.rightKey]) + .limit(PAGE_ROWS) + if (mysql(k)) { + // InnoDB does not reliably range-optimize the equivalent tuple inequality. + void source + .whereRaw('(?? > ? OR (?? = ? AND ?? > ?))', [ + relation.leftKey, + state.afterLeftId, + relation.leftKey, + state.afterLeftId, + relation.rightKey, + state.afterRightId + ]) + .forUpdate() + } else { + void source.whereRaw('(??, ??) > (?, ?)', [ + relation.leftKey, + relation.rightKey, + state.afterLeftId, + state.afterRightId + ]) + } + const rows: Array> = await source + for (const row of rows) { + positive(row[relation.leftKey]) + positive(row[relation.rightKey]) + } + await runInSeries(['left', 'right'] as const, async side => { + const { table, key, bit } = sideInfo(relation, side) + const ids = [...new Set(rows.map(row => row[key]))].sort((a, b) => a - b) + if (ids.length === 0) return + const query = trx(table).select(key, 'userId').whereIn(key, ids).orderBy(key) + if (mysql(k)) void query.forShare() + const parents: Array> = await query + const owners = new Map(parents.map(row => [positive(row[key]), positive(row.userId)])) + const memberships = rows.flatMap(row => { + const owner = owners.get(row[key]) + return owner === undefined + ? [] + : [ + { + snapshotTableId: tableId, + snapshotUserId: owner, + snapshotLeftId: row[relation.leftKey], + snapshotRightId: row[relation.rightKey], + snapshotMembership: bit + } + ] + }) + if (memberships.length !== 0) + await trx(KEYS) + .insert(memberships) + .onConflict(keyColumns) + .merge({ snapshotMembership: trx.raw('?? | ?', ['snapshotMembership', bit]) }) + }) + const last = rows.at(-1) + const complete = rows.length < PAGE_ROWS + await trx(PROGRESS) + .where('snapshotTableId', tableId) + .update({ + afterLeftId: last?.[relation.leftKey] ?? state.afterLeftId, + afterRightId: last?.[relation.rightKey] ?? state.afterRightId, + complete + }) + return complete + }) +} + +/** Keep standard schema and cursor order intact while indexing OR ownership. */ +export async function addSnapshotRelationIndexes(k: Knex): Promise { + if (mysql(k) && k.isTransaction) + throw new WERR_INVALID_OPERATION('Snapshot relation migration requires independent DDL and bootstrap transactions') + await ensureTables(k) + const { observers, producers } = triggers(mysql(k)) + // Every graph-wide removal observer precedes every possible producer. + await runInSeries([...observers, ...producers], async expected => { + if (!(await validateTrigger(k, expected))) await k.raw(expected.sql) + }) + await runInSeries(snapshotNumericRelations.entries(), async ([tableId, relation]) => { + await k(PROGRESS) + .insert({ snapshotTableId: tableId, afterLeftId: 0, afterRightId: 0, complete: false }) + .onConflict('snapshotTableId') + .ignore() + let complete = false + function* unfinishedPages() { + while (!complete) yield undefined + } + await runInSeries(unfinishedPages(), async () => { + complete = await bootstrapPage(k, relation, tableId) + }) + }) +} + +/** Readers must be drained before removal; standard rows remain intact. */ +export async function removeSnapshotRelationIndexes(k: Knex): Promise { + if (mysql(k) && k.isTransaction) + throw new WERR_INVALID_OPERATION('Snapshot relation migration requires independent DDL and bootstrap transactions') + if (await k.schema.hasTable(KEYS)) await validateTable(k, KEYS) + if (await k.schema.hasTable(PROGRESS)) await validateTable(k, PROGRESS) + const { observers, producers } = triggers(mysql(k)) + const ordered = [...producers, ...observers] + await runInSeries(ordered, async expected => { + await validateTrigger(k, expected) + }) + await runInSeries(ordered, async expected => { + await k.raw('DROP TRIGGER IF EXISTS ??', [expected.name]) + }) + await k.schema.dropTableIfExists(PROGRESS) + await k.schema.dropTableIfExists(KEYS) +} + +/** Read inside the same retained view as the snapshot header and table pages. */ +export async function readSnapshotRelationIndexState(k: Knex, config?: Knex.MigratorConfig): Promise { + const tableName = config?.tableName ?? 'knex_migrations' + const schema = k.schema + if (config?.schemaName !== undefined) void schema.withSchema(config.schemaName) + if (!(await schema.hasTable(tableName))) return false + const journal = k(tableName).where('name', SNAPSHOT_RELATION_INDEX_MIGRATION) + if (config?.schemaName !== undefined) void journal.withSchema(config.schemaName) + if ((await journal.first('name')) === undefined) return false + if (!(await k.schema.hasTable(KEYS)) || !(await k.schema.hasTable(PROGRESS))) + throw new WERR_INVALID_OPERATION('Snapshot relation index migration is incomplete') + await validateTable(k, KEYS) + await validateTable(k, PROGRESS) + const states: Array = await k(PROGRESS) + .select('snapshotTableId', 'afterLeftId', 'afterRightId', 'complete') + .orderBy('snapshotTableId') + .limit(snapshotNumericRelations.length + 1) + if ( + states.length !== snapshotNumericRelations.length || + states.some( + (state, i) => + state.snapshotTableId !== i || !validPosition(state) || (state.complete !== true && state.complete !== 1) + ) + ) + throw new WERR_INVALID_OPERATION('Snapshot relation index migration is incomplete') + return true +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts index aa74a9c6d..0a36b0d83 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -67,7 +67,7 @@ test('a ready request waits for owned reader destruction while foreground storag const source = (await storage.openSnapshotArchiveSource(identity))! expect(source.user.identityKey).toBe(identity) expect(source.sourceStorage.storageIdentityKey).toBe('original-source') - expect(source.sourceSchema).toBe('2026-10-01-003 add snapshot profile key indexes') + expect(source.sourceSchema).toBe('2026-10-01-004 add snapshot relation key indexes') const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex expect(reader.knex).not.toBe(storage.knex) expect(reader.knex.client.config.pool).toMatchObject({ min: 0, max: 1 }) @@ -350,3 +350,24 @@ test.each([new Error('synthetic guarded read failure'), undefined])( expect(await storage.knex('snapshot_archive_owners')).toHaveLength(0) } ) + +test('destroying an active guarded reader preserves its ordinary read error for the consumer after proved cleanup', async () => { + const storage = await fixture() + const requests = new KnexSnapshotArchiveRequestStore(storage.knex, true, true) + const offered = await requests.offer(identity, { lifetimeMs: 300000, maxBytes: 32768 }) + const { owner } = await requests.claimReader(identity, offered.request) + const failure = new Error('synthetic read completion failure after native closure') + const read = ArchiveGuard.readGuardedSnapshotArchive + jest.spyOn(ArchiveGuard, 'readGuardedSnapshotArchive').mockImplementationOnce(async (...args) => { + await read(...args) + throw failure + }) + const source = (await storage.openSnapshotArchiveSource(identity, {}, owner))! + const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex + const consumer = source.closed.catch(error => error) + await expect(reader.destroy()).resolves.toBeUndefined() + expect(await consumer).toBe(failure) + await expect(reader.knex.raw('SELECT 1')).rejects.toThrow('Unable to acquire a connection') + await requests.sourceClosed(owner!) + expect(await storage.knex('snapshot_archive_owners')).toHaveLength(0) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts index 9bf3f8b9c..2a24a1cce 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotSyncSource.test.ts @@ -113,6 +113,25 @@ test('dynamic connection providers and externally shared pools retain the legacy expect(open).not.toHaveBeenCalled() }) +test('provider shutdown completes unfinished database telemetry once with cancelled status', async () => { + const capture = jest.fn() + const source = mysql({ sink: { capture }, enabled: true }) + source.knex.emit('query', { + __knexQueryUid: 'synthetic-unfinished-query', + method: 'select', + sql: 'private fixture payload' + }) + expect(capture).not.toHaveBeenCalled() + await source.destroy() + stores.splice(stores.indexOf(source), 1) + expect(capture).toHaveBeenCalledTimes(1) + expect(capture.mock.calls[0][0]).toMatchObject({ name: 'wallet.storage.db.query', spanStatus: 'cancelled' }) + expect(JSON.stringify(capture.mock.calls)).not.toContain('private fixture payload') + source.knex.emit('query-response', [], { __knexQueryUid: 'synthetic-unfinished-query' }) + await source.destroy() + expect(capture).toHaveBeenCalledTimes(1) +}) + test.each([undefined, '', ':memory:', 'file:synthetic-snapshot'])( 'SQLite filename %s cannot open an independent retained reader', async filename => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts index f6e113dac..5e5edd3e9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts @@ -1,6 +1,7 @@ import { knex } from 'knex' import { StorageKnex } from '../StorageKnex' import { StorageProvider } from '../StorageProvider' +import { walletSnapshotSourceQuery } from './KnexWalletReadSnapshot' const identity = '02' + '11'.repeat(32) const when = new Date('2026-01-01T00:00:00.000Z') @@ -44,7 +45,12 @@ function fixture(fieldCount = 6, bytes = 524, certificate = false, deleted: bool if (certificate) { expect(query.sql).toContain('case when octet_length(`fieldName`) <= 400 then `fieldName` end as `fieldName`') expect(query.sql).toContain('`certificate_fields`.`userId` = ?') - callback(null, [{ fieldName, certificateId: 1, __snapshotBytes: bytes, __snapshotOwned: 1 }], []) + const after = query.sql.includes('`fieldName` > ?') + if (after) { + expect(query.sql).toContain('OR (`fieldName` = ? AND `certificateId` > ?)') + expect(bindings.slice(-4)).toEqual([fieldName, fieldName, 1, 128]) + } + callback(null, after ? [] : [{ fieldName, certificateId: 1, __snapshotBytes: bytes, __snapshotOwned: 1 }], []) } else { expect(query.sql).toContain('octet_length(`label`)') expect(query.sql).toContain('`tx_labels`.`userId` = ?') @@ -110,6 +116,11 @@ test('MySQL preflights complete Unicode keys and preserves a boolean-returning p const page = await view.readPage('certificateFields') expect(page.rows[0].fieldName).toBe('😀'.repeat(100)) expect(page.cursor?.after).toEqual(['😀'.repeat(100), 1]) + expect(await view.readPage('certificateFields', page.cursor)).toMatchObject({ + rows: [], + done: true, + payloadBytes: 0 + }) await view.close() } finally { await source.destroy() @@ -124,6 +135,25 @@ test('MySQL preflights complete Unicode keys and preserves a boolean-returning p } }) +test.each([ + ['txLabelMaps', 'tx_labels_map', 'txLabelId', 'transactionId', 0], + ['outputTagMaps', 'output_tags_map', 'outputTagId', 'outputId', 1] +] as const)( + 'MySQL %s uses the cursor-order auxiliary primary key and both map keys', + (table, name, left, right, tableId) => { + const k = knex({ client: 'mysql2' }) + const query = walletSnapshotSourceQuery(k, table, 41, true, true).select(`${name}.*`).toSQL() + expect(query.sql).toContain('from `snapshot_relation_keys` FORCE INDEX (`PRIMARY`) cross join `' + name + '`') + expect(query.sql).toContain( + '`snapshotLeftId` = `' + name + '`.`' + left + '` and `snapshotRightId` = `' + name + '`.`' + right + '`' + ) + expect(query.bindings).toEqual([tableId, 41]) + const legacy = walletSnapshotSourceQuery(k, table, 41).toSQL() + expect(legacy.sql).not.toContain('snapshot_relation_keys') + expect(legacy.sql).toContain('or exists') + } +) + test.each([6, 64])( 'MySQL metadata resolves DATABASE() without a configured connection database and caches %s columns', async count => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts index a6bf6f4df..ac963e089 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts @@ -14,6 +14,11 @@ import type { WalletSnapshotTables } from './WalletReadSnapshot' import { runInSeries } from '../../utility/runInSeries' +import { createKnexWalletSnapshotPageReader, walletSnapshotSourceQuery } from './KnexWalletReadSnapshot' +import { + removeSnapshotRelationIndexes, + SNAPSHOT_RELATION_INDEX_MIGRATION +} from '../schema/snapshotRelationIndexMigration' const identity = '02' + '11'.repeat(32) const foreignIdentity = '03' + '22'.repeat(32) @@ -85,6 +90,35 @@ afterEach(async () => { await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) }) +test('omitted auxiliary-index arguments preserve legacy profile queries and retained pages before migration', async () => { + const { source, userId, otherId } = await fixture() + await removeSnapshotProfileIndexes(source.knex) + await source.knex('knex_migrations').where('name', SNAPSHOT_PROFILE_INDEX_MIGRATION).delete() + await removeSnapshotRelationIndexes(source.knex) + await source.knex('knex_migrations').where('name', SNAPSHOT_RELATION_INDEX_MIGRATION).delete() + await labels(source, userId, 2) + await labels(source, otherId, 2) + const expected = await source.findTxLabels({ partial: { userId } }) + const view = await source.openReadSnapshot() + try { + const selected = await view.read(trx => + walletSnapshotSourceQuery(source.toDb(trx), 'txLabels', userId).select('txLabelId').orderBy('txLabelId') + ) + expect(selected).toEqual(expected.map(({ txLabelId }) => ({ txLabelId }))) + const reader = createKnexWalletSnapshotPageReader(source, userId, '12'.repeat(32), view) + const page = await reader('txLabels', undefined, { maxRows: 10 }) + expect(page.rows).toEqual(expected) + expect(page.done).toBe(true) + expect(page.cursor?.after).toEqual([expected.at(-1)!.txLabelId]) + expect( + await view.read(trx => walletSnapshotSourceQuery(source.toDb(trx), 'txLabelMaps', userId).select('*')) + ).toEqual([]) + expect((await reader('txLabelMaps')).rows).toEqual([]) + } finally { + await view.close() + } +}) + test('keyset pages pin profile, source metadata, equal timestamps and tombstones across independent writes', async () => { const { source, writer, userId, otherId } = await fixture() await labels(source, userId, 9) @@ -520,14 +554,23 @@ test('concurrent reads refuse instead of queueing and expiry invalidates cursors await view.closed }) -test.each([false, true])( - 'SQL keys support numeric and composite forward seeks without OFFSET or full counts (profileIndexes=%s)', - async profileIndexes => { +test.each([ + [false, false], + [false, true], + [true, false], + [true, true] +])( + 'SQL keys support forward seeks without OFFSET or counts (profileIndexes=%s, relationIndexes=%s)', + async (profileIndexes, relationIndexes) => { const { source, userId, otherId } = await fixture() if (!profileIndexes) { await removeSnapshotProfileIndexes(source.knex) await source.knex('knex_migrations').where('name', SNAPSHOT_PROFILE_INDEX_MIGRATION).delete() } + if (!relationIndexes) { + await removeSnapshotRelationIndexes(source.knex) + await source.knex('knex_migrations').where('name', SNAPSHOT_RELATION_INDEX_MIGRATION).delete() + } await seedClosure(source, userId, otherId) const view = await source.openWalletReadSnapshot(identity) const requests: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] @@ -544,6 +587,9 @@ test.each([false, true])( const subsequent = requests.filter(q => q.sql.includes(' > ')) expect(subsequent).toHaveLength(6) expect(subsequent.filter(query => query.sql.includes('snapshot_profile_keys'))).toHaveLength(profileIndexes ? 2 : 0) + expect(subsequent.filter(query => query.sql.includes('snapshot_relation_keys'))).toHaveLength( + relationIndexes ? 2 : 0 + ) for (const query of subsequent) { expect(query.sql).not.toMatch(/offset|count\(/i) const plan = await source.knex.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) @@ -554,6 +600,11 @@ test.each([false, true])( ) expect(details).toMatch(/SEARCH tx_labels USING INTEGER PRIMARY KEY \(rowid=\?\)/) expect(details).not.toMatch(/SCAN |TEMP B-TREE/) + } else if (query.sql.includes('snapshot_relation_keys')) { + expect(details).toMatch( + /SEARCH snapshot_relation_keys USING COVERING INDEX .*snapshotTableId=\? AND snapshotUserId=\? AND \(snapshotLeftId,snapshotRightId\)>\(\?,\?\)/ + ) + expect(details).not.toMatch(/SCAN |TEMP B-TREE/) } else { expect(details).toMatch(/SEARCH (tx_labels|tx_labels_map|certificate_fields) USING .*\(.*>\(?\?/) } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index 23e451cc6..2f81666f6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -1,3 +1,4 @@ +import { readSnapshotRelationIndexState } from '../schema/snapshotRelationIndexMigration' import { readSnapshotProfileIndexState } from '../schema/snapshotProfileIndexMigration' import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' import { Random, Utils } from '@bsv/sdk' @@ -54,6 +55,8 @@ const auxiliaryTableIds: Partial> = { syncStates: 7 } +const auxiliaryRelationTableIds: Partial> = { txLabelMaps: 0, outputTagMaps: 1 } + function definition(table: WalletSnapshotTable): TableDefinition { if (!Object.hasOwn(definitions, table)) throw new WERR_INVALID_PARAMETER('table', 'a wallet snapshot table') return definitions[table] @@ -93,7 +96,26 @@ function position( } /** Lexicographic seek, using the same database collation as ORDER BY. */ -function seek(query: Knex.QueryBuilder, keys: string[], after: Array, inclusive = false): void { +function seek( + query: Knex.QueryBuilder, + keys: string[], + after: Array, + inclusive: boolean, + mysql: boolean +): void { + if (mysql && keys.length === 2) { + const first = inclusive ? '<' : '>' + const last = inclusive ? '<=' : '>' + void query.whereRaw(`(?? ${first} ? OR (?? = ? AND ?? ${last} ?))`, [ + keys[0], + after[0], + keys[0], + after[0], + keys[1], + after[1] + ]) + return + } void query.whereRaw( `(${keys.map(() => '??').join(', ')}) ${inclusive ? '<=' : '>'} (${keys.map(() => '?').join(', ')})`, [...keys, ...after] @@ -112,7 +134,8 @@ export function walletSnapshotSourceQuery( k: Knex, table: WalletSnapshotTable, userId: number, - profileIndexes = false + profileIndexes = false, + relationIndexes = false ): Knex.QueryBuilder { const { name } = definitions[table] const tableId = auxiliaryTableIds[table] @@ -121,6 +144,21 @@ export function walletSnapshotSourceQuery( .crossJoin(name, 'snapshotRowId', `${name}.${definitions[table].keys[0]}`) .where({ snapshotTableId: tableId, snapshotUserId: userId }) .where(`${name}.userId`, userId) + const relationId = auxiliaryRelationTableIds[table] + if (relationIndexes && relationId !== undefined) { + const [left, right] = definitions[table].keys + // The maintenance index begins with the same profile prefix. MySQL can + // choose it and sort the complete profile before LIMIT; bind paging to the + // auxiliary primary key whose suffix is the unchanged cursor order. + const relationKeys = String(k.client.config.client).includes('mysql') + ? k.raw('?? FORCE INDEX (??)', ['snapshot_relation_keys', 'PRIMARY']) + : 'snapshot_relation_keys' + return k(relationKeys) + .crossJoin(name, function () { + void this.on('snapshotLeftId', '=', `${name}.${left}`).andOn('snapshotRightId', '=', `${name}.${right}`) + }) + .where({ snapshotTableId: relationId, snapshotUserId: userId }) + } const query = k(name) if (table === 'provenTxReqs') { return query.whereExists(owned(k, 'transactions', 'txid', `${name}.txid`, userId)) @@ -228,6 +266,7 @@ interface SnapshotContext { userId: number snapshotId: string profileIndexes: boolean + relationIndexes: boolean columns: Map } @@ -262,7 +301,7 @@ async function readPage( after: Array | undefined, limits: { maxRows: number; maxBytes: number } ): Promise> { - const { storage, userId, columns, snapshotId, profileIndexes } = context + const { storage, userId, columns, snapshotId, profileIndexes, relationIndexes } = context const k = storage.toDb(trx) const schema = definitions[table] let fields = columns.get(table) @@ -271,10 +310,13 @@ async function readPage( if (fields.length === 0 || fields.length > 64) throw new WERR_INVALID_OPERATION('Unsupported snapshot schema') columns.set(table, fields) } - const orderKeys = !profileIndexes || auxiliaryTableIds[table] === undefined ? schema.keys : ['snapshotRowId'] + let orderKeys = schema.keys + if (profileIndexes && auxiliaryTableIds[table] !== undefined) orderKeys = ['snapshotRowId'] + if (relationIndexes && auxiliaryRelationTableIds[table] !== undefined) + orderKeys = ['snapshotLeftId', 'snapshotRightId'] const base = (): Knex.QueryBuilder => { - const q = walletSnapshotSourceQuery(k, table, userId, profileIndexes) - if (after !== undefined) seek(q, orderKeys, after) + const q = walletSnapshotSourceQuery(k, table, userId, profileIndexes, relationIndexes) + if (after !== undefined) seek(q, orderKeys, after, false, storage.dbtype === 'MySQL') for (const key of orderKeys) void q.orderBy(key) return q } @@ -293,7 +335,7 @@ async function readPage( let rows: Array> = [] if (end !== undefined) { const query = base().select(`${schema.name}.*`).limit(count) - seek(query, orderKeys, end, true) + seek(query, orderKeys, end, true, storage.dbtype === 'MySQL') const raw: Array> = await query rows = raw.map(row => normalize(storage, row, schema)) } @@ -306,9 +348,10 @@ export function createKnexWalletSnapshotPageReader( userId: number, snapshotId: string, view: RetainedReadSnapshot, - profileIndexes = false + profileIndexes = false, + relationIndexes = false ): WalletReadSnapshot['readPage'] { - const context: SnapshotContext = { storage, userId, snapshotId, profileIndexes, columns: new Map() } + const context: SnapshotContext = { storage, userId, snapshotId, profileIndexes, relationIndexes, columns: new Map() } return async ( table: T, cursor?: WalletSnapshotCursor, @@ -333,13 +376,14 @@ export async function openKnexWalletReadSnapshot( } const view = await storage.openReadSnapshot(options) try { - const { header, profileIndexes } = await view.read(async trx => { + const { header, profileIndexes, relationIndexes } = await view.read(async trx => { const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') return { header: { sourceStorage, user }, - profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations) + profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), + relationIndexes: await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations) } }) const userId = header.user.userId @@ -354,7 +398,7 @@ export async function openKnexWalletReadSnapshot( }, closed: view.closed, close: view.close, - readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view, profileIndexes) + readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view, profileIndexes, relationIndexes) } } catch (error) { // Keep the opening error authoritative while still awaiting physical cleanup. diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts index 28023e7b1..a6a132db4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts @@ -13,6 +13,12 @@ import { StorageProvider } from '../StorageProvider' import type { WalletSnapshotCursor } from './WalletReadSnapshot' import { runInSeries } from '../../utility/runInSeries' import { retainReadSnapshot } from './RetainedReadSnapshot' +import { addSnapshotRelationIndexes } from '../schema/snapshotRelationIndexMigration' +import { + expectRelationMembership, + minimalRelationDatabase, + relationFixtures +} from '../../../test/utils/snapshotRelationFixtures' const MIN_PROPERTY_RUNS = 300 const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) @@ -24,6 +30,108 @@ fc.configureGlobal({ ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) }) +test('generated relation writes preserve OR ownership through parent moves, rekeys, tombstones, rollback and restart', async () => { + const k = await minimalRelationDatabase() + try { + await addSnapshotRelationIndexes(k) + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + pair: fc.integer({ min: 0, max: 1 }), + left: fc.integer({ min: 1, max: 5 }), + right: fc.integer({ min: 1, max: 5 }), + owner: fc.integer({ min: 1, max: 3 }), + kind: fc.constantFrom( + 'left', + 'right', + 'map', + 'tombstone', + 'delete-map', + 'delete-left', + 'delete-right', + 'left-key', + 'right-key', + 'map-key', + 'rollback', + 'repeat' + ) + }), + { minLength: 1, maxLength: 25 } + ), + async schedule => { + await runInSeries(relationFixtures, async p => { + await k(p.table).delete() + await k(p.left).delete() + await k(p.right).delete() + }) + await runInSeries(schedule, async op => { + const p = relationFixtures[op.pair] + if (op.kind === 'left') + await k(p.left) + .insert({ [p.leftKey]: op.left, userId: op.owner }) + .onConflict(p.leftKey) + .merge() + else if (op.kind === 'right') + await k(p.right) + .insert({ [p.rightKey]: op.right, userId: op.owner }) + .onConflict(p.rightKey) + .merge() + else if (op.kind === 'map') + await k(p.table) + .insert({ [p.leftKey]: op.left, [p.rightKey]: op.right, isDeleted: false }) + .onConflict([p.leftKey, p.rightKey]) + .ignore() + else if (op.kind === 'tombstone') + await k(p.table) + .where({ [p.leftKey]: op.left, [p.rightKey]: op.right }) + .update({ isDeleted: true }) + else if (op.kind === 'delete-map') + await k(p.table) + .where({ [p.leftKey]: op.left, [p.rightKey]: op.right }) + .delete() + else if (op.kind === 'delete-left') await k(p.left).where(p.leftKey, op.left).delete() + else if (op.kind === 'delete-right') await k(p.right).where(p.rightKey, op.right).delete() + else if (op.kind === 'left-key' || op.kind === 'right-key' || op.kind === 'map-key') { + try { + if (op.kind === 'left-key') + await k(p.left) + .where(p.leftKey, op.left) + .update({ [p.leftKey]: (op.left % 5) + 1 }) + else if (op.kind === 'right-key') + await k(p.right) + .where(p.rightKey, op.right) + .update({ [p.rightKey]: (op.right % 5) + 1 }) + else + await k(p.table) + .where({ [p.leftKey]: op.left, [p.rightKey]: op.right }) + .update({ [p.leftKey]: (op.left % 5) + 1, [p.rightKey]: (op.right % 5) + 1 }) + } catch (error) { + if ((error as { code?: string }).code !== 'SQLITE_CONSTRAINT_PRIMARYKEY') throw error + } + } else if (op.kind === 'repeat') await addSnapshotRelationIndexes(k) + else { + const failure = new Error('synthetic relation rollback') + await expect( + k.transaction(async trx => { + await trx(p.left) + .insert({ [p.leftKey]: op.left, userId: op.owner }) + .onConflict(p.leftKey) + .merge() + throw failure + }) + ).rejects.toBe(failure) + } + await expectRelationMembership(k) + }) + } + ) + ) + } finally { + await k.destroy() + } +}) + function gate() { let resolve!: () => void const promise = new Promise(yes => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts index 459bc996a..58f8d6add 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts @@ -118,7 +118,12 @@ test('indexed ordinary/archive pages preserve all13tables and original source in expect(await legacy()).toEqual(originalOffsets) const queries: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] const listen = (query: { sql: string; bindings: Knex.RawBinding[] }): void => { - if (query.sql.startsWith('select') && query.sql.includes('cross join')) queries.push(query) + if ( + query.sql.startsWith('select') && + query.sql.includes('cross join') && + query.sql.includes('snapshot_profile_keys') + ) + queries.push(query) } k.on('query', listen) try { @@ -167,7 +172,12 @@ test.each(['ordinary', 'archive'] as const)( : await openKnexSnapshotArchiveSource(source, identity) const queries: string[] = [] const listen = (query: { sql: string }): void => { - if (query.sql.startsWith('select') && query.sql.includes('cross join')) queries.push(query.sql) + if ( + query.sql.startsWith('select') && + query.sql.includes('cross join') && + query.sql.includes('snapshot_profile_keys') + ) + queries.push(query.sql) } k.on('query', listen) const old = await open() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts index 4a24078eb..d38206f56 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts @@ -5,6 +5,7 @@ import { knex } from 'knex' import { StorageKnex } from '../StorageKnex' import { StorageProvider } from '../StorageProvider' import { KnexMigrations } from '../schema/KnexMigrations' +import { SNAPSHOT_RELATION_INDEX_MIGRATION } from '../schema/snapshotRelationIndexMigration' import { readSnapshotProfileIndexState, SNAPSHOT_PROFILE_INDEX_MIGRATION @@ -64,7 +65,7 @@ test('registered profile bootstrap survives reopening and publishes its journal database = knex(options) source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: database }) await expect(source.migrate('profile migration', 'synthetic-profile-migration')).resolves.toBe( - SNAPSHOT_PROFILE_INDEX_MIGRATION + SNAPSHOT_RELATION_INDEX_MIGRATION ) expect(await readSnapshotProfileIndexState(database)).toBe(true) expect(await database('snapshot_profile_keys').where('snapshotTableId', 3).count({ count: '*' }).first()).toEqual({ diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.integration.test.ts new file mode 100644 index 000000000..990e444df --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.integration.test.ts @@ -0,0 +1,216 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex, type Knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { runInSeries } from '../../utility/runInSeries' +import { seedArchiveClosure } from '../../../test/utils/snapshotArchiveFixtures' +import { snapshotArchiveTables } from './archive/SnapshotArchive' +import { openKnexSnapshotArchiveSource, type SnapshotArchiveSource } from './archive/KnexSnapshotArchiveSource' +import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' +import { + addSnapshotRelationIndexes, + removeSnapshotRelationIndexes, + SNAPSHOT_RELATION_INDEX_MIGRATION +} from '../schema/snapshotRelationIndexMigration' + +const identity = '02' + '11'.repeat(32) +const stores: StorageKnex[] = [] +const writers: Knex[] = [] +const directories: string[] = [] +const dates = { created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z' } +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-relation-index-')) + directories.push(directory) + const options = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + } + const k = knex(options) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + stores.push(source) + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('relation index fixture', 'synthetic-relation-index') + await source.makeAvailable() + // Start with the immediately preceding schema on both old and new source. + await removeSnapshotRelationIndexes(k) + await k('knex_migrations').where('name', SNAPSHOT_RELATION_INDEX_MIGRATION).delete() + const { user } = await source.findOrInsertUser(identity) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, other.userId) + const writer = knex(options) + writers.push(writer) + return { source, writer, k, userId: user.userId, otherId: other.userId } +} +async function journal(k: Knex): Promise { + await k('knex_migrations').insert({ name: SNAPSHOT_RELATION_INDEX_MIGRATION, batch: 99, migration_time: new Date() }) +} +async function pages(view: WalletReadSnapshot | SnapshotArchiveSource) { + expect(Object.hasOwn(view, 'relationIndexes')).toBe(false) + if ('validateClosure' in view) await view.validateClosure() + const result: Record = {} + await runInSeries(snapshotArchiveTables, async table => { + let cursor: WalletSnapshotCursor | undefined + const selected: unknown[] = [] + let complete = false + for (let pageNumber = 0; pageNumber < 25 && !complete; pageNumber++) { + const page = await view.readPage(table, cursor, { maxRows: 1, maxBytes: 131072 }) + selected.push({ rows: page.rows, after: page.cursor?.after, payloadBytes: page.payloadBytes, done: page.done }) + complete = page.done + cursor = page.cursor + } + expect(complete).toBe(true) + result[table] = selected + }) + return result +} +afterEach(async () => { + await runInSeries(writers.splice(0), k => k.destroy()) + await runInSeries(stores.splice(0), source => source.destroy()) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) + +test('indexed ordinary/archive pages preserve all13tables and original source indexes/legacy offsets', async () => { + const { source, k, userId } = await fixture() + const legacy = async () => { + const all: Record = {} + await runInSeries( + [ + ['findTransactions', 'transactionId'], + ['findOutputs', 'outputId'], + ['findCertificates', 'certificateId'], + ['findTxLabels', 'txLabelId'], + ['findOutputBaskets', 'basketId'], + ['findOutputTags', 'outputTagId'], + ['findCommissions', 'commissionId'], + ['findSyncStates', 'syncStateId'] + ] as const, + async ([method, key]) => { + all[method] = [] + await runInSeries([0, 1], async offset => { + const rows = await source[method]({ partial: { userId }, paged: { limit: 1, offset } }) + all[method].push(rows.map(row => (row as unknown as Record)[key])) + }) + } + ) + return all + } + const standard = async () => + await k('sqlite_master') + .whereIn('type', ['table', 'index']) + .whereNotIn('tbl_name', ['snapshot_relation_keys', 'snapshot_relation_index_progress']) + .select('type', 'name', 'tbl_name', 'sql') + .orderBy('name') + const originalIndexes = await standard() + const originalOffsets = await legacy() + const old = await source.openWalletReadSnapshot(identity) + let baseline + try { + baseline = await pages(old) + } finally { + await old.close() + } + await addSnapshotRelationIndexes(k) + expect(await standard()).toEqual(originalIndexes) + expect(await legacy()).toEqual(originalOffsets) + const queries: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] + const listen = (query: { sql: string; bindings: Knex.RawBinding[] }): void => { + if ( + query.sql.startsWith('select') && + query.sql.includes('cross join') && + query.sql.includes('snapshot_relation_keys') + ) + queries.push(query) + } + k.on('query', listen) + try { + const partial = await source.openWalletReadSnapshot(identity) + try { + expect(await pages(partial)).toEqual(baseline) + expect(queries).toEqual([]) + } finally { + await partial.close() + } + await journal(k) + await runInSeries( + [() => source.openWalletReadSnapshot(identity), () => openKnexSnapshotArchiveSource(source, identity)], + async open => { + const view = await open() + try { + expect(await pages(view)).toEqual(baseline) + } finally { + await view.close() + } + } + ) + expect(queries.length).toBeGreaterThan(0) + } finally { + k.off('query', listen) + } + // Explain only page queries; closure may legitimately visit its relational parents. + await runInSeries( + queries.filter(query => query.sql.includes('__snapshotBytes') || query.sql.includes('.*')), + async query => { + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) + expect(plan.some(row => row.detail.includes('SEARCH snapshot_relation_keys USING COVERING INDEX'))).toBe(true) + expect(plan.some(row => /SCAN |TEMP B-TREE/.test(row.detail))).toBe(false) + } + ) +}) + +test.each(['ordinary', 'archive'] as const)( + '%s mode and all13table pages stay bound across independent journal/profile/progress writes', + async kind => { + const { source, k, writer, userId, otherId } = await fixture() + await addSnapshotRelationIndexes(k) + const open = async () => + kind === 'ordinary' + ? await source.openWalletReadSnapshot(identity) + : await openKnexSnapshotArchiveSource(source, identity) + const queries: string[] = [] + const listen = (query: { sql: string }): void => { + if ( + query.sql.startsWith('select') && + query.sql.includes('cross join') && + query.sql.includes('snapshot_relation_keys') + ) + queries.push(query.sql) + } + k.on('query', listen) + const old = await open() + let baseline + try { + baseline = await pages(old) + queries.length = 0 + await journal(writer) + expect(await pages(old)).toEqual(baseline) + expect(queries).toEqual([]) + } finally { + await old.close() + } + const indexed = await open() + try { + await writer.transaction(async trx => { + await trx('snapshot_relation_index_progress').where('snapshotTableId', 0).update({ complete: 0 }) + await trx('tx_labels').where('txLabelId', 3).update({ userId: otherId, label: 'moved' }) + await trx('tx_labels').insert({ ...dates, txLabelId: 4, userId, label: 'new after view', isDeleted: false }) + }) + expect(await pages(indexed)).toEqual(baseline) + expect(queries.length).toBeGreaterThan(0) + } finally { + await indexed.close() + k.off('query', listen) + } + await expect(open()).rejects.toThrow('migration is incomplete') + await writer('snapshot_relation_index_progress').where('snapshotTableId', 0).update({ complete: 1 }) + const fresh = await open() + try { + expect((await fresh.readPage('txLabels')).rows.map(row => row.txLabelId)).toEqual([1, 4]) + } finally { + await fresh.close() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.mysql.test.ts new file mode 100644 index 000000000..85e7cf478 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.mysql.test.ts @@ -0,0 +1,373 @@ +import { knex } from 'knex' +import { + addSnapshotRelationIndexes, + removeSnapshotRelationIndexes, + readSnapshotRelationIndexState, + SNAPSHOT_RELATION_INDEX_MIGRATION, + snapshotNumericRelations +} from '../schema/snapshotRelationIndexMigration' + +type MetadataKind = 'columns' | 'indexes' | 'triggers' +type Metadata = Array> + +/** Installed MySQL compiler and transaction protocol; native fixtures separately prove database semantics. */ +function mysqlFixture(change: (kind: MetadataKind, rows: Metadata) => unknown = (_kind, rows) => rows) { + const k = knex({ client: 'mysql2' }) + const queries: Array<{ sql: string; values: unknown[] }> = [] + const tables = new Set(['knex_migrations']) + const indexes = new Map() + const triggers = new Map>() + const progress = new Map< + number, + { snapshotTableId: number; afterLeftId: number; afterRightId: number; complete: boolean } + >() + const keys: Array<{ + snapshotTableId: number + snapshotUserId: number + snapshotLeftId: number + snapshotRightId: number + snapshotMembership: number + }> = [] + let journaled = false + const answer = (sql: string, values: unknown[]): unknown => { + queries.push({ sql, values }) + if (sql.startsWith('select * from information_schema.tables')) + return tables.has(String(values[0])) ? [{ TABLE_NAME: values[0] }] : [] + if (sql.startsWith('create table')) { + tables.add(sql.match(/^create table `([^`]+)`/)![1]) + return [] + } + if (sql.startsWith('drop table')) { + tables.delete(sql.match(/`([^`]+)`/)![1]) + return [] + } + if (sql.startsWith('SELECT COLUMN_NAME')) { + const isKeys = values[0] === 'snapshot_relation_keys' + const names = isKeys + ? ['snapshotTableId', 'snapshotUserId', 'snapshotLeftId', 'snapshotRightId', 'snapshotMembership'] + : ['snapshotTableId', 'afterLeftId', 'afterRightId', 'complete'] + const types = isKeys + ? ['int', 'int unsigned', 'int unsigned', 'int unsigned', 'int unsigned'] + : ['int', 'int unsigned', 'int unsigned', 'tinyint'] + return change( + 'columns', + names.map((name, i) => ({ name, type: types[i], nullable: 'NO', defaultValue: null, extra: '' })) + ) + } + if (sql.startsWith('alter table `snapshot_relation_keys` add index')) { + const name = sql.match(/add index `([^`]+)`/)![1] + indexes.set( + name, + [...sql.matchAll(/`([^`]+)`/g)].slice(2).map(match => match[1]) + ) + return [] + } + if (sql.startsWith('SELECT INDEX_NAME FROM')) + return indexes.has(String(values[1])) ? [{ INDEX_NAME: values[1] }] : [] + if (sql.startsWith('SELECT INDEX_NAME AS')) { + const primary = + values[0] === 'snapshot_relation_keys' + ? ['snapshotTableId', 'snapshotUserId', 'snapshotLeftId', 'snapshotRightId'] + : ['snapshotTableId'] + return change('indexes', [ + ...primary.map(columnName => ({ name: 'PRIMARY', columnName, nonUnique: 0, direction: 'A', prefix: null })), + ...(values[0] === 'snapshot_relation_keys' + ? [...indexes].flatMap(([name, columns]) => + columns.map(columnName => ({ name, columnName, nonUnique: 1, direction: 'A', prefix: null })) + ) + : []) + ]) + } + if (sql.startsWith('SELECT EVENT_MANIPULATION')) + return change('triggers', triggers.has(String(values[0])) ? [triggers.get(String(values[0]))!] : []) + if (sql.startsWith('CREATE TRIGGER')) { + const [, name, timing, event, tableName, body] = sql.match( + /^CREATE TRIGGER (\w+) (BEFORE|AFTER) (\w+) ON (\w+) FOR EACH ROW (.*)$/ + )! + triggers.set(name, { event, timing, tableName, body }) + return [] + } + if (sql.startsWith('DROP TRIGGER')) { + triggers.delete(sql.split(' ').at(-1)!.replaceAll('`', '')) + return [] + } + if (sql.startsWith('insert ignore into `snapshot_relation_index_progress`')) { + const [afterLeftId, afterRightId, complete, snapshotTableId] = values as [number, number, boolean, number] + if (!progress.has(snapshotTableId)) + progress.set(snapshotTableId, { snapshotTableId, afterLeftId, afterRightId, complete }) + return { affectedRows: 1, insertId: 0 } + } + if (sql.startsWith('select * from `snapshot_relation_index_progress`')) { + expect(sql).toMatch(/for update$/) + const row = progress.get(Number(values[0])) + return row === undefined ? [] : [{ ...row }] + } + if (sql.startsWith('select `snapshotTableId`, `afterLeftId`, `afterRightId`, `complete`')) + return [...progress.values()].map(row => ({ ...row })).slice(0, Number(values[0])) + if (sql.startsWith('update `snapshot_relation_index_progress`')) { + const [afterLeftId, afterRightId, complete, tableId] = values as [number, number, boolean, number] + Object.assign(progress.get(tableId)!, { afterLeftId, afterRightId, complete }) + return { affectedRows: 1 } + } + if (sql.startsWith('insert into `snapshot_relation_keys`')) { + const fields = sql + .slice(sql.indexOf('(') + 1, sql.indexOf(')')) + .split(', ') + .map(value => value.replaceAll('`', '')) + for (let i = 0; i < values.length - 1; i += fields.length) { + const row = Object.fromEntries( + fields.map((field, offset) => [field, Number(values[i + offset])]) + ) as (typeof keys)[number] + const existing = keys.find( + key => + key.snapshotTableId === row.snapshotTableId && + key.snapshotUserId === row.snapshotUserId && + key.snapshotLeftId === row.snapshotLeftId && + key.snapshotRightId === row.snapshotRightId + ) + if (existing === undefined) keys.push(row) + else existing.snapshotMembership |= Number(values.at(-1)) + } + return { affectedRows: 1 } + } + if (sql.startsWith('select `name` from `knex_migrations`')) + return journaled ? [{ name: SNAPSHOT_RELATION_INDEX_MIGRATION }] : [] + if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return [] + const source = snapshotNumericRelations.find(({ table }) => sql.includes('from `' + table + '`')) + if (source !== undefined) { + expect(sql).toMatch(/for update$/) + expect(values.at(-1)).toBe(256) + expect(sql).toContain(' OR ') + return [ + { [source.leftKey]: 1, [source.rightKey]: 1 }, + { [source.leftKey]: 3, [source.rightKey]: 2 } + ].filter(row => row[source.leftKey] > Number(values[0])) + } + const parent = snapshotNumericRelations + .flatMap(p => [ + [p.left, p.leftKey], + [p.right, p.rightKey] + ]) + .find(([table]) => sql.includes('from `' + table + '`')) + if (parent !== undefined) { + expect(sql).toMatch(/lock in share mode$/) + return values.map(id => ({ [parent[1]]: Number(id), userId: Number(id) === 1 ? 1 : 2 })) + } + throw new Error('Unexpected synthetic driver query: ' + sql) + } + const connection = { + query( + query: { sql: string }, + values: unknown[], + callback: (error: Error | null, rows?: unknown, fields?: unknown[]) => void + ) { + try { + callback(null, answer(query.sql, values), []) + } catch (error) { + callback(error as Error) + } + } + } + jest.spyOn(k.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(k.client, 'releaseConnection').mockResolvedValue(undefined) + return { + k, + queries, + keys, + progress, + triggers, + tables, + journal: () => { + journaled = true + } + } +} + +afterEach(() => jest.restoreAllMocks()) + +test('MySQL DDL and locked bounded bootstrap preserve completion ordering and support repeat/removal', async () => { + const f = mysqlFixture() + try { + await addSnapshotRelationIndexes(f.k) + expect(f.triggers.size).toBe(24) + expect(f.keys).toEqual( + snapshotNumericRelations.flatMap((_table, snapshotTableId) => [ + { snapshotTableId, snapshotUserId: 1, snapshotLeftId: 1, snapshotRightId: 1, snapshotMembership: 3 }, + { snapshotTableId, snapshotUserId: 2, snapshotLeftId: 3, snapshotRightId: 2, snapshotMembership: 3 } + ]) + ) + expect([...f.progress.values()]).toEqual( + snapshotNumericRelations.map((_table, snapshotTableId) => ({ + snapshotTableId, + afterLeftId: 3, + afterRightId: 2, + complete: true + })) + ) + expect( + f.queries.filter(query => query.sql.includes('for update') && !query.sql.includes('snapshot_relation')) + ).toHaveLength(2) + expect(f.queries.find(query => query.sql.startsWith('create table `snapshot_relation_keys`'))!.sql).toContain( + 'primary key (`snapshotTableId`, `snapshotUserId`, `snapshotLeftId`, `snapshotRightId`)' + ) + expect(f.queries.filter(query => query.sql === 'BEGIN;')).toHaveLength(2) + expect(f.queries.filter(query => query.sql === 'COMMIT;')).toHaveLength(2) + f.journal() + expect(await readSnapshotRelationIndexState(f.k)).toBe(true) + await addSnapshotRelationIndexes(f.k) + expect(f.keys).toHaveLength(4) + await removeSnapshotRelationIndexes(f.k) + expect(f.triggers.size).toBe(0) + expect(f.tables.has('snapshot_relation_keys')).toBe(false) + } finally { + await f.k.destroy() + } +}) + +test.each([ + ['columns', []], + ['columns', null], + ['indexes', []], + ['indexes', null] +] as const)('MySQL missing/non-array %s metadata refuses before installing triggers', async (kind, replacement) => { + const f = mysqlFixture((current, rows) => (current === kind ? replacement : rows)) + try { + await expect(addSnapshotRelationIndexes(f.k)).rejects.toThrow('table definition mismatch') + expect(f.triggers.size).toBe(0) + expect(f.keys).toEqual([]) + } finally { + await f.k.destroy() + } +}) + +test.each([ + { name: 'unexpected' }, + { type: 'bigint' }, + { nullable: 'YES' }, + { defaultValue: 0 }, + { extra: 'auto_increment' } +])('MySQL column metadata mismatch %j refuses without bootstrap', async changed => { + const f = mysqlFixture((kind, rows) => (kind === 'columns' ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows)) + try { + await expect(addSnapshotRelationIndexes(f.k)).rejects.toThrow('table definition mismatch') + expect(f.keys).toEqual([]) + } finally { + await f.k.destroy() + } +}) + +test.each([{ direction: 'D' }, { prefix: 1 }, { columnName: 'unexpected' }])( + 'MySQL primary-key mismatch %j refuses adoption', + async changed => { + const f = mysqlFixture((kind, rows) => (kind === 'indexes' ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows)) + try { + await expect(addSnapshotRelationIndexes(f.k)).rejects.toThrow('table definition mismatch') + } finally { + await f.k.destroy() + } + } +) + +test('MySQL extra unique constraints refuse while nonunique indexes remain compatible', async () => { + for (const nonUnique of [0, 1]) { + const f = mysqlFixture((kind, rows) => + kind === 'indexes' ? [...rows, { name: 'extra', columnName: 'snapshotRowId', nonUnique }] : rows + ) + try { + if (nonUnique === 0) await expect(addSnapshotRelationIndexes(f.k)).rejects.toThrow('table definition mismatch') + else { + await addSnapshotRelationIndexes(f.k) + expect(f.keys).toHaveLength(4) + } + } finally { + await f.k.destroy() + } + } +}) + +test('a MySQL migration in an outer transaction refuses before DDL can commit it implicitly', async () => { + const f = mysqlFixture() + try { + await f.k.transaction(async trx => { + await expect(addSnapshotRelationIndexes(trx)).rejects.toThrow('independent DDL') + await expect(removeSnapshotRelationIndexes(trx)).rejects.toThrow('independent DDL') + }) + expect(f.queries.every(query => /^(BEGIN|COMMIT)/.test(query.sql))).toBe(true) + } finally { + await f.k.destroy() + } +}) + +test.each([ + { event: 'UNKNOWN' }, + { timing: 'BEFORE' }, + { tableName: 'foreign_table' }, + { body: 'BEGIN SELECT 1; END' } +])('MySQL stored trigger mismatch %j refuses adoption/removal without deleting definitions', async changed => { + let corrupt = false + const f = mysqlFixture((kind, rows) => (corrupt && kind === 'triggers' ? [{ ...rows[0], ...changed }] : rows)) + try { + await addSnapshotRelationIndexes(f.k) + corrupt = true + await expect(addSnapshotRelationIndexes(f.k)).rejects.toThrow('trigger definition mismatch') + await expect(removeSnapshotRelationIndexes(f.k)).rejects.toThrow('trigger definition mismatch') + expect(f.triggers.size).toBe(24) + expect(f.tables.has('snapshot_relation_keys')).toBe(true) + } finally { + await f.k.destroy() + } +}) + +test.each([null, [undefined], [{}, {}]])( + 'MySQL malformed trigger metadata %p refuses before creation', + async replacement => { + const f = mysqlFixture((kind, rows) => (kind === 'triggers' ? replacement : rows)) + try { + await expect(addSnapshotRelationIndexes(f.k)).rejects.toThrow(/trigger (metadata|definition mismatch)/) + expect(f.triggers.size).toBe(0) + expect(f.keys).toEqual([]) + } finally { + await f.k.destroy() + } + } +) + +test('MySQL native-width metadata and harmless body whitespace do not require recreation', async () => { + const f = mysqlFixture((kind, rows) => + kind === 'columns' + ? rows.map(row => ({ ...row, type: String(row.type).replace('int', 'int(11)') })) + : kind === 'triggers' + ? rows.map(row => ({ ...row, body: String(row.body).replace(/ /g, ' \n ') })) + : rows + ) + try { + await addSnapshotRelationIndexes(f.k) + const created = f.queries.filter(query => query.sql.startsWith('CREATE TRIGGER')).length + await addSnapshotRelationIndexes(f.k) + expect(f.queries.filter(query => query.sql.startsWith('CREATE TRIGGER'))).toHaveLength(created) + expect(f.keys).toHaveLength(4) + } finally { + await f.k.destroy() + } +}) + +test('pool-only MySQL uses its actual database and preserves the explicit custom journal schema', async () => { + const f = mysqlFixture() + try { + expect(await readSnapshotRelationIndexState(f.k)).toBe(false) + expect(f.queries[0]).toEqual({ + sql: 'select * from information_schema.tables where table_name = ? and table_schema = database()', + values: ['knex_migrations'] + }) + expect( + await readSnapshotRelationIndexState(f.k, { tableName: 'custom_journal', schemaName: 'custom_schema' }) + ).toBe(false) + expect(f.queries.at(-1)).toEqual({ + sql: 'select * from information_schema.tables where table_name = ? and table_schema = ?', + values: ['custom_journal', 'custom_schema'] + }) + expect(f.keys).toEqual([]) + } finally { + await f.k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.test.ts new file mode 100644 index 000000000..a29630792 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.test.ts @@ -0,0 +1,256 @@ +import type { Knex } from 'knex' +import { runInSeries } from '../../utility/runInSeries' +import { expectRelationMembership, minimalRelationDatabase } from '../../../test/utils/snapshotRelationFixtures' +import { + addSnapshotRelationIndexes as install, + removeSnapshotRelationIndexes as remove, + readSnapshotRelationIndexState as enabled, + SNAPSHOT_RELATION_INDEX_MIGRATION as migration +} from '../schema/snapshotRelationIndexMigration' + +const databases: Knex[] = [] +async function fixture(): Promise { + const k = await minimalRelationDatabase() + databases.push(k) + return k +} +afterEach(async () => { + jest.restoreAllMocks() + await runInSeries(databases.splice(0), k => k.destroy()) +}) + +test.each([0, 1, 255, 256, 257])( + 'bootstrap resumes the composite cursor for %p mappings without altering source indexes', + async count => { + const k = await fixture() + await k('tx_labels').insert({ txLabelId: 1, userId: 1 }) + if (count) { + await k('transactions').insert( + Array.from({ length: count }, (_, i) => ({ transactionId: i + 1, userId: (i % 2) + 1 })) + ) + await k('tx_labels_map').insert( + Array.from({ length: count }, (_, i) => ({ txLabelId: 1, transactionId: i + 1, isDeleted: i % 2 })) + ) + } + const schema = () => + k('sqlite_master').whereIn('type', ['table', 'index']).select('name', 'type', 'sql').orderBy('name') + const before = await schema() + await install(k) + await install(k) + await expectRelationMembership(k) + expect(await k('snapshot_relation_index_progress').where('snapshotTableId', 0).first()).toEqual({ + snapshotTableId: 0, + afterLeftId: count ? 1 : 0, + afterRightId: count, + complete: 1 + }) + await remove(k) + await remove(k) + expect(await schema()).toEqual(before) + expect(await k('tx_labels_map')).toHaveLength(count) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) + } +) + +test('a committed bootstrap page resumes after failure and preserves lower-key writes and owner changes', async () => { + const k = await fixture() + await k('tx_labels').insert({ txLabelId: 1, userId: 1 }) + await k('transactions').insert(Array.from({ length: 300 }, (_, i) => ({ transactionId: i + 2, userId: 1 }))) + await k('tx_labels_map').insert( + Array.from({ length: 300 }, (_, i) => ({ txLabelId: 1, transactionId: i + 2, isDeleted: false })) + ) + const failure = new Error('synthetic relation bootstrap interruption') + let reads = 0 + const interrupt = (query: { sql: string }): void => { + if (query.sql.startsWith('select `txLabelId`, `transactionId` from `tx_labels_map`') && ++reads === 2) throw failure + } + k.on('query', interrupt) + try { + await expect(install(k)).rejects.toBe(failure) + } finally { + k.off('query', interrupt) + } + expect(await k('snapshot_relation_index_progress').where('snapshotTableId', 0).first()).toEqual({ + snapshotTableId: 0, + afterLeftId: 1, + afterRightId: 257, + complete: 0 + }) + expect(await k('snapshot_relation_keys')).toHaveLength(256) + await k('transactions').insert({ transactionId: 1, userId: 2 }) + await k('tx_labels_map').insert({ txLabelId: 1, transactionId: 1, isDeleted: true }) + await k('tx_labels').where('txLabelId', 1).update({ userId: 3 }) + await k('tx_labels_map').where('transactionId', 2).delete() + await install(k) + await expectRelationMembership(k) +}) + +test('membership retains both ownership bases, cross-profile and orphan visibility, and physical deletion semantics', async () => { + const k = await fixture() + await install(k) + await k('tx_labels').insert({ txLabelId: 1, userId: 1 }) + await k('transactions').insert({ transactionId: 1, userId: 1 }) + await k('tx_labels_map').insert({ txLabelId: 1, transactionId: 1, isDeleted: true }) + await expectRelationMembership(k) + expect((await k('snapshot_relation_keys').first()).snapshotMembership).toBe(3) + await k('tx_labels').where('txLabelId', 1).update({ userId: 2 }) + await expectRelationMembership(k) + expect(await k('snapshot_relation_keys')).toHaveLength(2) + await k('transactions').delete() + await expectRelationMembership(k) + expect((await k('snapshot_relation_keys').first()).snapshotMembership).toBe(1) + await k('tx_labels_map').delete() + await expectRelationMembership(k) + expect(await k('snapshot_relation_keys')).toEqual([]) +}) + +test('journal, complete positions and exact tables are required before selecting indexed reads', async () => { + const k = await fixture() + expect(await enabled(k)).toBe(false) + await install(k) + expect(await enabled(k)).toBe(false) + await k.schema.createTable('custom_journal', t => { + t.string('name') + }) + const config = { tableName: 'custom_journal', schemaName: 'main' } + await k('custom_journal').insert({ name: 'unrelated migration' }) + expect(await enabled(k, config)).toBe(false) + await k('custom_journal').insert({ name: migration }) + expect(await enabled(k, config)).toBe(true) + await k('snapshot_relation_index_progress').where('snapshotTableId', 1).update({ complete: 0 }) + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') + await k('snapshot_relation_index_progress').where('snapshotTableId', 1).update({ complete: 1, afterRightId: -1 }) + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') + await k('snapshot_relation_index_progress').where('snapshotTableId', 1).delete() + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') + await k.schema.dropTable('snapshot_relation_index_progress') + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') + await k.schema.dropTable('snapshot_relation_keys') + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') +}) + +test.each([ + { afterLeftId: -1 }, + { afterRightId: -1 }, + { afterLeftId: 1.5 }, + { afterRightId: 'invalid' }, + { afterLeftId: Number.MAX_SAFE_INTEGER + 1 }, + { complete: 2 } +])('invalid progress %j refuses before reading source maps', async change => { + const k = await fixture() + await install(k) + await k('snapshot_relation_index_progress') + .where('snapshotTableId', 0) + .update({ complete: 0, ...change }) + const read = jest.fn() + k.on('query', read) + await expect(install(k)).rejects.toThrow('bootstrap position') + expect(read.mock.calls.some(([q]) => q.sql.startsWith('select `txLabelId`, `transactionId` from'))).toBe(false) +}) + +test.each([0, -1, 1.5, 'invalid', Number.MAX_SAFE_INTEGER + 1])( + 'invalid parent owner %p rolls back its page and checkpoint', + async userId => { + const k = await fixture() + await k('tx_labels').insert({ txLabelId: 1, userId }) + await k('tx_labels_map').insert({ txLabelId: 1, transactionId: 1, isDeleted: false }) + await expect(install(k)).rejects.toThrow('source key') + expect(await k('snapshot_relation_keys')).toEqual([]) + expect(await k('snapshot_relation_index_progress').where('snapshotTableId', 0).first()).toEqual({ + snapshotTableId: 0, + afterLeftId: 0, + afterRightId: 0, + complete: 0 + }) + } +) + +test('conflicting trigger definitions refuse adoption and removal before dropping any observer or producer', async () => { + const k = await fixture() + await install(k) + await k.raw('DROP TRIGGER snapshot_relation_1_right_delete') + await k.raw('CREATE TRIGGER snapshot_relation_1_right_delete AFTER DELETE ON outputs BEGIN SELECT 1; END') + const before = await k('sqlite_master').where('type', 'trigger').orderBy('name') + await expect(install(k)).rejects.toThrow('trigger definition mismatch') + await expect(remove(k)).rejects.toThrow('trigger definition mismatch') + expect(await k('sqlite_master').where('type', 'trigger').orderBy('name')).toEqual(before) + expect(await k.schema.hasTable('snapshot_relation_keys')).toBe(true) +}) + +test.each([ + 'CREATE UNIQUE INDEX foreign_unique_relation ON snapshot_relation_keys(snapshotLeftId)', + 'DROP INDEX snapshot_relation_right', + 'CREATE INDEX foreign_partial_relation ON snapshot_relation_keys(snapshotRightId) WHERE snapshotMembership=1' +])('schema inspection preserves unrelated definitions: %s', async sql => { + const k = await fixture() + await install(k) + await k.raw(sql) + if (sql.startsWith('CREATE INDEX foreign_partial')) { + await install(k) + await expectRelationMembership(k) + } else if (sql.startsWith('DROP')) { + await expect(remove(k)).rejects.toThrow('table definition mismatch') + await install(k) + expect(await k('sqlite_master').where({ type: 'index', name: 'snapshot_relation_right' })).toHaveLength(1) + } else { + await expect(install(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') + expect(await k('sqlite_master').where({ type: 'index', name: 'foreign_unique_relation' })).toHaveLength(1) + } +}) + +test('partial, reversed or wrong-column maintenance indexes cannot silently replace required indexes', async () => { + const k = await fixture() + await install(k) + await k.raw('DROP INDEX snapshot_relation_right') + await k.raw( + 'CREATE INDEX snapshot_relation_right ON snapshot_relation_keys(snapshotTableId,snapshotUserId,snapshotRightId DESC,snapshotLeftId)' + ) + await expect(install(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') +}) + +test('a foreign auxiliary table shape refuses adoption and removal without changing its rows', async () => { + const k = await fixture() + await k.schema.createTable('snapshot_relation_keys', t => { + t.text('unrelated') + }) + await k('snapshot_relation_keys').insert({ unrelated: 'preserve' }) + await expect(install(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') + expect(await k('snapshot_relation_keys')).toEqual([{ unrelated: 'preserve' }]) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) +}) + +test('missing primary-index metadata refuses before installing triggers or publishing progress', async () => { + const k = await fixture() + const omit = (rows: unknown, query: { sql: string }): void => { + if (query.sql.startsWith('PRAGMA index_list(') && query.sql.includes('snapshot_relation_keys')) { + expect(Array.isArray(rows)).toBe(true) + ;(rows as unknown[]).splice(0) + } + } + k.on('query-response', omit) + try { + await expect(install(k)).rejects.toThrow('table definition mismatch') + } finally { + k.off('query-response', omit) + } + expect(await k.schema.hasTable('snapshot_relation_index_progress')).toBe(false) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) +}) + +test('bootstrap retains the existing owner of an inconsistent mapping and permits a missing ownership basis', async () => { + const k = await fixture() + await k('tx_labels').insert({ txLabelId: 1, userId: 1 }) + await k('tx_labels_map').insert([ + { txLabelId: 1, transactionId: 1, isDeleted: true }, + { txLabelId: 2, transactionId: 2, isDeleted: false } + ]) + await install(k) + await expectRelationMembership(k) + expect(await k('snapshot_relation_keys')).toEqual([ + { snapshotTableId: 0, snapshotUserId: 1, snapshotLeftId: 1, snapshotRightId: 1, snapshotMembership: 1 } + ]) + expect(await k('snapshot_relation_index_progress').where('complete', 1)).toHaveLength(2) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index 851fcaf16..ad9669432 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' -import { SNAPSHOT_PROFILE_INDEX_MIGRATION } from '../../schema/KnexMigrations' +import { SNAPSHOT_RELATION_INDEX_MIGRATION } from '../../schema/KnexMigrations' import { decodeSyncTransfer } from '../../remoting/SyncTransfer' import * as Transfer from '../../remoting/SyncTransfer' import * as ArchiveSource from './KnexSnapshotArchiveSource' @@ -72,7 +72,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { onProgress: p => progress.push(p) }) - expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_PROFILE_INDEX_MIGRATION) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_RELATION_INDEX_MIGRATION) expect(manifest.binding.sourceStorage.storageName).toBe('original source') expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) @@ -84,7 +84,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof sourceStorageIdentityKey: 'original-source', archiveId: manifest.archiveId, digest: manifest.digest, - sourceSchema: SNAPSHOT_PROFILE_INDEX_MIGRATION + sourceSchema: SNAPSHOT_RELATION_INDEX_MIGRATION }) expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} @@ -153,7 +153,7 @@ test('source schema, primary history and closure stay pinned while an independen await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) - expect(source.sourceSchema).toBe(SNAPSHOT_PROFILE_INDEX_MIGRATION) + expect(source.sourceSchema).toBe(SNAPSHOT_RELATION_INDEX_MIGRATION) expect(source.user.activeStorage).toBe(originalPrimary) await expect(source.validateClosure()).resolves.toBeUndefined() expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts index 0d277b753..023dc374c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts @@ -124,7 +124,12 @@ const references: readonly Reference[] = [ * constant marker is returned for an invalid relation; blobs and full ID maps * are never loaded. This does not parse or authenticate BRC-38/39 documents. */ -export async function assertKnexSnapshotArchiveClosure(k: Knex, userId: number, profileIndexes = false): Promise { +export async function assertKnexSnapshotArchiveClosure( + k: Knex, + userId: number, + profileIndexes = false, + relationIndexes = false +): Promise { if (!Number.isSafeInteger(userId) || userId < 1) throw new WERR_INVALID_PARAMETER('userId', 'a positive safe ID') await runInSeries(references, async reference => { const column = `${reference.source}.${reference.field}` @@ -132,7 +137,7 @@ export async function assertKnexSnapshotArchiveClosure(k: Knex, userId: number, .select(k.raw('1')) .whereRaw('?? = ??', [`${reference.target}.${reference.key}`, column]) if (reference.profile) void target.where(`${reference.target}.userId`, userId) - const invalid = walletSnapshotSourceQuery(k, reference.table, userId, profileIndexes) + const invalid = walletSnapshotSourceQuery(k, reference.table, userId, profileIndexes, relationIndexes) .select(k.raw('1 AS invalid')) .whereNotExists(target) if (reference.optional === true) void invalid.whereNotNull(column) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index cc9e74b32..915d02a6c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -1,3 +1,4 @@ +import { readSnapshotRelationIndexState } from '../../schema/snapshotRelationIndexMigration' import { readSnapshotProfileIndexState } from '../../schema/snapshotProfileIndexMigration' import { Random, Utils } from '@bsv/sdk' import type { Knex } from 'knex' @@ -51,7 +52,7 @@ export async function openKnexSnapshotArchiveSource( } const view = await openView() try { - const { header, profileIndexes } = await view.read(async trx => { + const { header, profileIndexes, relationIndexes } = await view.read(async trx => { const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') @@ -61,7 +62,8 @@ export async function openKnexSnapshotArchiveSource( user, sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) }, - profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations) + profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), + relationIndexes: await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations) } }) const userId = header.user.userId @@ -76,9 +78,11 @@ export async function openKnexSnapshotArchiveSource( }, closed: view.closed, close: view.close, - readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view, profileIndexes), + readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view, profileIndexes, relationIndexes), validateClosure: async () => { - await view.read(trx => assertKnexSnapshotArchiveClosure(storage.toDb(trx), userId, profileIndexes)) + await view.read(trx => + assertKnexSnapshotArchiveClosure(storage.toDb(trx), userId, profileIndexes, relationIndexes) + ) } } } catch (error) { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index 105ca34fc..cb5daefc1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -5,6 +5,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { knex, type Knex } from 'knex' import { addSnapshotArchiveTables, removeSnapshotArchiveTables } from '../../schema/snapshotArchiveMigration' +import { addSnapshotArchiveOwnerTable } from '../../schema/snapshotArchiveOwnerMigration' +import { lockSnapshotArchiveCapacity } from './SnapshotArchiveSql' +import { + assignSnapshotArchiveOwner, + releaseSnapshotArchiveOwner, + reserveSnapshotArchiveOwner, + SnapshotArchiveCleanupPendingError +} from './SnapshotArchiveOwner' import { verifySnapshotArchiveDirectory, verifySnapshotArchivePage } from './SnapshotArchiveDirectory' import { KnexSnapshotArchiveStore, @@ -119,6 +127,68 @@ test('mainnet source metadata survives sealing and cross-connection reads', asyn await expect(store.inspect(identity, writer.archiveId)).rejects.toThrow('unavailable') }) +test('cleanup retains live source pages and quota while reaping an unrelated expired archive', async () => { + const { db, peer, store } = await fixture() + await addSnapshotArchiveOwnerTable(db) + const writer = await store.begin(binding) + await complete(store, writer) + const owner = { identityKey: identity, requestId: '1'.repeat(64), claimToken: '2'.repeat(64) } + await db.transaction(async trx => { + await lockSnapshotArchiveCapacity(trx) + await reserveSnapshotArchiveOwner(trx, owner) + await assignSnapshotArchiveOwner(trx, owner, writer.archiveId) + }) + const independent = await store.begin({ ...binding, user: { ...binding.user, identityKey: other } }) + await complete(store, independent) + await db('snapshot_archives').update({ expiresAt: 0 }) + await expect(store.close(identity, writer.archiveId)).rejects.toBeInstanceOf(SnapshotArchiveCleanupPendingError) + expect(await db('snapshot_archive_pages').where('archiveId', writer.archiveId)).toHaveLength(13) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 2 }) + const replacement = new KnexSnapshotArchiveStore(peer) + await replacement.reap() + expect(await db('snapshot_archives').select('archiveId', 'state')).toEqual([ + { archiveId: writer.archiveId, state: 'closing' } + ]) + expect(await db('snapshot_archive_pages').where('archiveId', writer.archiveId)).toHaveLength(13) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ + archives: 1, + reservedBytes: snapshotArchiveLimits.archiveBytes + }) + await releaseSnapshotArchiveOwner(peer, owner) + await replacement.reap() + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + +test('reaping exposes storage failure and retains capacity until a later successful cleanup', async () => { + const { db, peer, store } = await fixture() + const writer = await store.begin(binding) + await complete(store, writer) + await db('snapshot_archives').where('archiveId', writer.archiveId).update({ expiresAt: 0 }) + const failure = new Error('synthetic page deletion failure') + const interrupt = (query: { sql: string }): void => { + if (query.sql.startsWith('delete from `snapshot_archive_pages`')) throw failure + } + peer.on('query', interrupt) + const replacement = new KnexSnapshotArchiveStore(peer) + try { + await expect(replacement.reap()).rejects.toBe(failure) + } finally { + peer.off('query', interrupt) + } + expect(await db('snapshot_archives').first('state')).toEqual({ state: 'closing' }) + expect(await db('snapshot_archive_pages')).toHaveLength(13) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ + archives: 1, + reservedBytes: snapshotArchiveLimits.archiveBytes + }) + await replacement.reap() + expect(await db('snapshot_archives')).toHaveLength(0) + expect(await db('snapshot_archive_pages')).toHaveLength(0) + expect(await db('snapshot_archive_capacity').first()).toMatchObject({ archives: 0, reservedBytes: 0 }) +}) + test('a replacement server returns verified inclusion metadata without selecting payloads or writer credentials', async () => { const { db, peer, store } = await fixture() const writer = await store.begin(binding) @@ -631,7 +701,7 @@ test('only acknowledged sequence positions are readable, even if an unacknowledg test('the auxiliary migration is registered after the durable sync schema', async () => { const migrations = new KnexMigrations('test', 'source', 'source', 1024) - expect(await migrations.getLatestMigration()).toBe('2026-10-01-003 add snapshot profile key indexes') + expect(await migrations.getLatestMigration()).toBe('2026-10-01-004 add snapshot relation key indexes') }) test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts index ecd40aa00..1d74bb103 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts @@ -33,7 +33,7 @@ test.each([StorageClient, StorageMobile])( expect(storage.getSettings()).not.toHaveProperty('snapshotArchive') let transport = (await client.getSnapshotArchiveTransport(identityKey))! const offer = await transport.offer() - expect(offer.sourceSchema).toBe('2026-10-01-003 add snapshot profile key indexes') + expect(offer.sourceSchema).toBe('2026-10-01-004 add snapshot relation key indexes') expect(Math.abs(offer.serverTime - Date.now())).toBeLessThan(5000) const fields = { version: 1 as const, diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs index 2678183ae..67ecccb5a 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs @@ -165,6 +165,8 @@ async function main() { console.log(JSON.stringify({ ownerProcessLoss: await qualifySQLiteGuardProcessLoss() })) const { qualifySQLiteProfileIndexProcessLoss } = require('./snapshotProfileIndexCrash.cjs') console.log(JSON.stringify({ profileIndexProcessLoss: await qualifySQLiteProfileIndexProcessLoss() })) + const { qualifySQLiteRelationIndexProcessLoss } = require('./snapshotRelationIndexCrash.cjs') + console.log(JSON.stringify({ relationIndexProcessLoss: await qualifySQLiteRelationIndexProcessLoss() })) } } main().catch(error => { diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index 7a1726cb5..6dbbbdf60 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -145,7 +145,7 @@ async function captureFixture() { assert.equal(manifest.pages, 14) assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') assert.equal(manifest.binding.user.activeStorage, 'historical selection') - assert.equal(manifest.binding.sourceSchema, '2026-10-01-003 add snapshot profile key indexes') + assert.equal(manifest.binding.sourceSchema, '2026-10-01-004 add snapshot relation key indexes') const store = new KnexSnapshotArchiveStore(writer.knex) const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) @@ -433,7 +433,7 @@ async function requestFixture(writer, reader) { sourceStorageIdentityKey: 'native-source', digest: ready.digest }) - assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-003 add snapshot profile key indexes') + assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-004 add snapshot relation key indexes') const page = await replacement.read(identity, ready.archiveId, 8) const decoded = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[8])) assert.equal(decoded.rows[0].label, 'replacement') @@ -538,6 +538,12 @@ async function main() { const profileIndexProcessLoss = await qualifyMysqlProfileIndexProcessLoss(database, connection) const { qualifyMysqlProfileIndexLocks } = require('./snapshotProfileIndexMysql.cjs') const profileIndexLocks = await qualifyMysqlProfileIndexLocks(database, connection) + const { qualifyMysqlRelationIndexProcessLoss } = require('./snapshotRelationIndexCrash.cjs') + const relationIndexProcessLoss = await qualifyMysqlRelationIndexProcessLoss(database, connection) + const { qualifyMysqlRelationIndexLocks } = require('./snapshotRelationIndexMysql.cjs') + const relationIndexLocks = await qualifyMysqlRelationIndexLocks(database, connection) + const { qualifyMysqlRelationIndexSeeks } = require('./snapshotRelationIndexSeeks.cjs') + const relationIndexSeeks = await qualifyMysqlRelationIndexSeeks(database, connection) console.log( JSON.stringify({ version, @@ -552,6 +558,9 @@ async function main() { idempotentPartialDdl: true, profileIndexProcessLoss, profileIndexLocks, + relationIndexProcessLoss, + relationIndexLocks, + relationIndexSeeks, capture }) ) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexCrash.cjs new file mode 100644 index 000000000..cb0f2448f --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexCrash.cjs @@ -0,0 +1,324 @@ +// Synthetic process-loss qualification, invoked by the existing native fixtures. +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { randomUUID } = require('node:crypto') +const fs = require('node:fs/promises') +const { writeFileSync } = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { KnexMigrations } = require('../../out/src/storage/schema/KnexMigrations.js') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + readSnapshotRelationIndexState, + snapshotNumericRelations, + SNAPSHOT_RELATION_INDEX_MIGRATION +} = require('../../out/src/storage/schema/snapshotRelationIndexMigration.js') + +const phases = [ + 'after-key-table', + 'after-first-index', + 'partial-observers', + 'partial-producers', + 'before-cursor', + 'after-cursor', + 'after-commit' +] +const migrationName = 'synthetic relation crash' +const migrationIdentity = 'synthetic-relation-crash' +const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } +const provider = options => + new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: knex(options) }) + +async function seed(options) { + const source = provider(options) + try { + if (options.client === 'better-sqlite3') await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate(migrationName, migrationIdentity) + await source.makeAvailable() + const migrationSource = new KnexMigrations('test', migrationName, migrationIdentity, 1024) + await source.knex.migrate.down({ + migrationSource, + name: SNAPSHOT_RELATION_INDEX_MIGRATION, + disableTransactions: false + }) + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await source + .knex('transactions') + .insert({ + ...dates, + transactionId: 1, + userId: user.userId, + status: 'completed', + reference: 'synthetic-relation', + isOutgoing: true, + satoshis: 0, + description: '' + }) + await source + .knex('outputs') + .insert({ + ...dates, + outputId: 1, + userId: user.userId, + transactionId: 1, + spendable: false, + change: false, + vout: 0, + satoshis: 1, + providedBy: 'you', + purpose: '', + type: 'P2PKH' + }) + await runInSeries(snapshotNumericRelations, async relation => { + await runInSeries([0, 1, 2], async batch => { + const rows = Array.from({ length: 200 }, (_, i) => batch * 200 + i + 1).filter(id => id !== 9) + const name = relation.left === 'tx_labels' ? 'label' : 'tag' + await source + .knex(relation.left) + .insert( + rows.map(id => ({ + ...dates, + [relation.leftKey]: id, + userId: user.userId, + [name]: `synthetic-${id}`, + isDeleted: false + })) + ) + await source + .knex(relation.table) + .insert( + rows.map(id => ({ ...dates, [relation.leftKey]: id, [relation.rightKey]: 1, isDeleted: id % 2 === 0 })) + ) + }) + }) + return { userId: user.userId, otherId: other.userId } + } finally { + await source.destroy() + } +} + +async function child(options, phase, marker) { + assert(phases.includes(phase)) + assert.equal(typeof process.send, 'function', 'Child requires its fixture parent') + const source = provider(options) + let cursorWritten = false + const cursor = query => + query.sql.startsWith('update `snapshot_relation_index_progress`') && + query.bindings[0] === 257 && + query.bindings[1] === 1 && + query.bindings.at(-1) === 0 + const park = event => { + writeFileSync(marker, JSON.stringify({ phase, event }), { mode: 0o600 }) + process.kill(process.pid, 'SIGKILL') + } + source.knex.on('query', query => { + if (phase === 'before-cursor' && cursor(query)) park('query') + if (phase === 'after-commit' && cursorWritten && query.sql.toLowerCase().startsWith('begin')) + park('next-transaction') + }) + source.knex.on('query-response', (_result, query) => { + const sql = query.sql.toLowerCase() + if (phase === 'after-key-table' && sql.startsWith('create table `snapshot_relation_keys`')) park('query-response') + if ( + phase === 'after-first-index' && + (sql.startsWith('create index `snapshot_relation_right`') || + sql.startsWith('alter table `snapshot_relation_keys` add index `snapshot_relation_right`')) + ) + park('query-response') + if (phase === 'partial-observers' && sql.startsWith('create trigger snapshot_relation_0_map_delete ')) + park('query-response') + if (phase === 'partial-producers' && sql.startsWith('create trigger snapshot_relation_0_map_insert ')) + park('query-response') + if (cursor(query)) { + cursorWritten = true + if (phase === 'after-cursor') park('query-response') + } + if (phase === 'after-commit' && cursorWritten && sql.startsWith('commit')) park('query-response') + }) + try { + await source.migrate(migrationName, migrationIdentity) + throw new Error('Expected migration boundary was not reached') + } finally { + await source.destroy() + } +} + +async function terminateAt(options, phase) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-relation-boundary-')) + const marker = path.join(directory, 'boundary.json') + const processHandle = spawn(process.execPath, [__filename, 'child'], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + let stderr = '' + processHandle.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-65536) + }) + const exited = new Promise((resolve, reject) => { + processHandle.once('exit', (code, signal) => resolve({ code, signal })) + processHandle.once('error', reject) + }) + const timer = setTimeout(() => processHandle.kill('SIGKILL'), 15000) + try { + processHandle.send({ options, phase, marker }) + const result = await exited + assert.equal(result.signal, 'SIGKILL', stderr) + const observed = JSON.parse(await fs.readFile(marker, 'utf8')) + assert.equal(observed.phase, phase) + return { phase, event: observed.event, signal: result.signal } + } finally { + clearTimeout(timer) + if (processHandle.exitCode === null && processHandle.signalCode === null) { + processHandle.kill('SIGKILL') + await exited + } + await fs.rm(directory, { recursive: true, force: true }) + } +} + +async function qualify(options, phase) { + const { userId, otherId } = await seed(options) + const outcome = await terminateAt(options, phase) + const source = provider(options) + const database = source.knex + try { + assert.equal(await readSnapshotRelationIndexState(database), false) + if (['before-cursor', 'after-cursor', 'after-commit'].includes(phase)) { + const committed = phase === 'after-commit' + const progress = await database('snapshot_relation_index_progress').where('snapshotTableId', 0).first() + assert.equal(progress.afterLeftId, committed ? 257 : 0) + assert.equal(progress.afterRightId, committed ? 1 : 0) + assert.equal( + Number( + (await database('snapshot_relation_keys').where('snapshotTableId', 0).count({ count: '*' }).first()).count + ), + committed ? 256 : 0 + ) + } + await runInSeries(snapshotNumericRelations, async relation => { + await database(relation.left).where(relation.leftKey, 3).update({ userId: otherId }) + const name = relation.left === 'tx_labels' ? 'label' : 'tag' + await database(relation.left).insert({ + ...dates, + [relation.leftKey]: 9, + userId, + [name]: 'reinserted-behind-cursor', + isDeleted: false + }) + await database(relation.table).insert({ + ...dates, + [relation.leftKey]: 9, + [relation.rightKey]: 1, + isDeleted: true + }) + await database(relation.table).where(relation.leftKey, 4).delete() + }) + // A killed migrator leaves Knex's lock claimed. This is fixture-owned recovery; + // the verified child is gone and no other migrator can own this isolated store. + await database.migrate.forceFreeMigrationsLock() + await source.migrate(migrationName, migrationIdentity) + assert.equal(await readSnapshotRelationIndexState(database), true) + await runInSeries(snapshotNumericRelations.entries(), async ([tableId, relation]) => { + const left = new Map((await database(relation.left)).map(row => [row[relation.leftKey], row.userId])) + const right = new Map((await database(relation.right)).map(row => [row[relation.rightKey], row.userId])) + const expected = [] + for (const row of await database(relation.table)) { + const owners = new Map() + for (const [userId, bit] of [ + [left.get(row[relation.leftKey]), 1], + [right.get(row[relation.rightKey]), 2] + ]) + if (userId !== undefined) owners.set(userId, (owners.get(userId) ?? 0) | bit) + for (const [userId, membership] of owners) + expected.push({ + snapshotTableId: tableId, + snapshotUserId: userId, + snapshotLeftId: row[relation.leftKey], + snapshotRightId: row[relation.rightKey], + snapshotMembership: membership + }) + } + expected.sort( + (a, b) => + a.snapshotUserId - b.snapshotUserId || + a.snapshotLeftId - b.snapshotLeftId || + a.snapshotRightId - b.snapshotRightId + ) + assert.deepEqual( + await database('snapshot_relation_keys') + .where('snapshotTableId', tableId) + .orderBy(['snapshotUserId', 'snapshotLeftId', 'snapshotRightId']), + expected + ) + }) + assert.equal( + Number( + ( + await database('knex_migrations') + .where('name', SNAPSHOT_RELATION_INDEX_MIGRATION) + .count({ count: '*' }) + .first() + ).count + ), + 1 + ) + return { ...outcome, journalPublishedAfterRecovery: true, independentProfileChangeAndLowIdInsert: true } + } finally { + await source.destroy() + } +} + +async function qualifySQLiteRelationIndexProcessLoss() { + const results = [] + await runInSeries(phases, async phase => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-relation-index-crash-')) + try { + results.push( + await qualify( + { + client: 'better-sqlite3', + connection: { filename: path.join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }, + phase + ) + ) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + }) + return results +} + +async function qualifyMysqlRelationIndexProcessLoss(control, connection) { + // The calling fixture has already verified its disposable, pinned container. + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const results = [] + await runInSeries(phases, async phase => { + const database = 'ts569_relation_' + randomUUID().replaceAll('-', '') + await control.raw('CREATE DATABASE ??', [database]) + try { + results.push( + await qualify({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }, phase) + ) + } finally { + await control.raw('DROP DATABASE ??', [database]) + } + }) + return results +} + +if (process.argv[2] === 'child') { + assert.equal(typeof process.send, 'function', 'Child execution requires its fixture parent') + process.once('message', ({ options, phase, marker }) => { + child(options, phase, marker).catch(error => { + console.error(error) + process.exitCode = 1 + process.disconnect() + }) + }) +} +module.exports = { qualifySQLiteRelationIndexProcessLoss, qualifyMysqlRelationIndexProcessLoss } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexMysql.cjs new file mode 100644 index 000000000..a76acae46 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexMysql.cjs @@ -0,0 +1,251 @@ +// Invoked only by the verified disposable native MySQL fixture. +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex } = require('knex') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + snapshotNumericRelations: relations, + addSnapshotRelationIndexes: install +} = require('../../out/src/storage/schema/snapshotRelationIndexMigration.js') +const settle = promise => + promise.then( + value => ({ ok: true, value }), + error => ({ ok: false, error }) + ) +function success(result) { + if (!result.ok) throw result.error +} +async function until(check) { + for (let i = 0; i < 1000; i++) { + const result = await check() + if (result) return result + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw Error('Native lock observation exceeded five seconds') +} + +async function fixture(admin, connection, isolation, relation, tableId) { + const database = 'ts569_relation_locks_' + randomUUID().replaceAll('-', '') + await admin.raw('CREATE DATABASE ??', [database]) + const open = () => knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + const k = open(), + writer = open(), + observer = open() + const events = [] + let transaction + try { + for (const client of [k, writer, observer]) + await client.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation.toUpperCase()) + for (const p of relations) { + for (const [table, key] of [ + [p.left, p.leftKey], + [p.right, p.rightKey] + ]) { + await k.schema.createTable(table, t => { + t.integer(key).primary() + t.integer('userId').notNullable() + }) + await k(table).insert({ [key]: 1, userId: 1 }) + } + await k.schema.createTable(p.table, t => { + t.integer(p.leftKey) + t.integer(p.rightKey) + t.boolean('isDeleted') + t.primary([p.leftKey, p.rightKey]) + t.index(p.rightKey) + }) + } + await install(k) + const p = relation + const mapping = { [p.leftKey]: 1, [p.rightKey]: 1, isDeleted: false } + const expected = () => [ + { snapshotTableId: tableId, snapshotUserId: 1, snapshotLeftId: 1, snapshotRightId: 1, snapshotMembership: 2 }, + { snapshotTableId: tableId, snapshotUserId: 2, snapshotLeftId: 1, snapshotRightId: 1, snapshotMembership: 1 } + ] + const verify = async () => + assert.deepEqual( + await k('snapshot_relation_keys').where('snapshotTableId', tableId).orderBy('snapshotUserId'), + expected() + ) + const reset = async () => { + await k(p.table).delete() + await k(p.left).where(p.leftKey, 1).update({ userId: 1 }) + } + const waiting = async (table, requester) => { + const [rows] = await observer.raw( + 'SELECT l.OBJECT_NAME AS tableName, l.LOCK_MODE AS lockMode, t.PROCESSLIST_ID AS requester FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID AND l.ENGINE=w.ENGINE JOIN performance_schema.threads t ON t.THREAD_ID=l.THREAD_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME=? AND t.PROCESSLIST_ID=?', + [table, requester] + ) + return rows.length ? rows : false + } + const connectionId = async client => Number((await client.raw('SELECT CONNECTION_ID() AS id'))[0][0].id) + const kId = await connectionId(k) + const writerId = await connectionId(writer) + + // The map cannot use an owner whose transaction has not committed yet. + transaction = await writer.transaction() + await transaction(p.left).where(p.leftKey, 1).update({ userId: 2 }) + let done = false + const first = settle( + k(p.table) + .insert(mapping) + .then(() => { + done = true + }) + ) + try { + // RR's parent producer protects the empty map range with a next-key lock, + // so the concurrent insert waits there before reaching its parent lookup. + const blockedTable = isolation === 'repeatable read' ? p.table : p.left + const locks = await until(() => waiting(blockedTable, kId)) + assert.ok( + locks.every(lock => + isolation === 'repeatable read' ? lock.lockMode.includes('INSERT_INTENTION') : lock.lockMode.startsWith('S') + ) + ) + assert.equal(done, false) + events.push({ case: 'map waits for committed parent owner', locks }) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + success(await first) + await verify() + + // The map producer retains parent protection until its own commit. + await reset() + transaction = await writer.transaction() + await transaction(p.table).insert(mapping) + done = false + const second = settle( + k(p.left) + .where(p.leftKey, 1) + .update({ userId: 2 }) + .then(() => { + done = true + }) + ) + try { + const locks = await until(() => waiting(p.left, kId)) + assert.equal(done, false) + events.push({ case: 'parent waits for map publication', locks }) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + success(await second) + await verify() + + await reset() + transaction = await k.transaction() + try { + assert.equal((await transaction(p.left).where(p.leftKey, 1).first()).userId, 1) + await writer(p.left).where(p.leftKey, 1).update({ userId: 2 }) + await transaction(p.table).insert(mapping) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + await verify() + events.push({ case: 'map producer ignores earlier consistent owner snapshot' }) + + await reset() + transaction = await k.transaction() + try { + assert.equal((await transaction(p.table)).length, 0) + await writer(p.table).insert(mapping) + await transaction(p.left).where(p.leftKey, 1).update({ userId: 2 }) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + await verify() + events.push({ case: 'parent producer ignores earlier consistent map snapshot' }) + + // Replay a bounded bootstrap while an independent owner change is pending. + await k('snapshot_relation_keys').where('snapshotTableId', tableId).delete() + await k('snapshot_relation_index_progress') + .where('snapshotTableId', tableId) + .update({ afterLeftId: 0, afterRightId: 0, complete: false }) + transaction = await writer.transaction() + await transaction(p.left).where(p.leftKey, 1).update({ userId: 1 }) + await transaction(p.left).where(p.leftKey, 1).update({ userId: 2 }) + const bootstrapWait = settle(install(k)) + try { + const locks = await until(() => waiting(p.table, kId)) + events.push({ case: 'bootstrap waits for current committed relation', locks }) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + success(await bootstrapWait) + await verify() + + // Pause after both source/parent reads but before auxiliary key publication. + await k(p.left).where(p.leftKey, 1).update({ userId: 1 }) + await k('snapshot_relation_keys').where('snapshotTableId', tableId).delete() + await k('snapshot_relation_index_progress') + .where('snapshotTableId', tableId) + .update({ afterLeftId: 0, afterRightId: 0, complete: false }) + let reached = false, + release + const gate = new Promise(resolve => { + release = resolve + }) + const prototype = Object.getPrototypeOf(k.client) + const original = prototype.query + const hadOwn = Object.hasOwn(prototype, 'query') + prototype.query = async function (conn, query) { + const sql = typeof query === 'string' ? query : query.sql + if (!reached && sql.startsWith('insert into `snapshot_relation_keys`')) { + reached = true + await gate + } + return await original.call(this, conn, query) + } + const bootstrap = settle(install(k)) + let update + try { + await until(async () => reached) + done = false + update = settle( + writer(p.left) + .where(p.leftKey, 1) + .update({ userId: 2 }) + .then(() => { + done = true + }) + ) + const locks = await until(() => waiting(p.left, writerId)) + assert.equal(done, false) + events.push({ case: 'bootstrap retains source protection through key and cursor commit', locks }) + } finally { + release() + if (hadOwn) prototype.query = original + else delete prototype.query + success(await bootstrap) + if (update !== undefined) success(await update) + } + await verify() + return { isolation, relation: p.table, events } + } finally { + if (transaction !== undefined && !transaction.isCompleted()) await transaction.rollback() + await observer.destroy() + await writer.destroy() + await k.destroy() + await admin.raw('DROP DATABASE ??', [database]) + } +} + +async function qualifyMysqlRelationIndexLocks(control, connection) { + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const results = [] + await runInSeries(['read committed', 'repeatable read'], async isolation => { + await runInSeries(relations.entries(), async ([tableId, relation]) => { + results.push(await fixture(control, connection, isolation, relation, tableId)) + }) + }) + return results +} +module.exports = { qualifyMysqlRelationIndexLocks } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs new file mode 100644 index 000000000..6bf9fd60e --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs @@ -0,0 +1,149 @@ +// Invoked only by the verified disposable native MySQL fixture. +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { + createKnexWalletSnapshotPageReader: pageReader +} = require('../../out/src/storage/snapshot/KnexWalletReadSnapshot.js') +async function largeSeeks(source, userId, otherId, mysql) { + const k = source.knex + const date = mysql ? new Date('2026-01-01T00:00:00Z') : '2026-01-01T00:00:00Z' + const common = { created_at: date, updated_at: date, isDeleted: false } + for (let start = 100; start < 8292; start += 256) { + const ids = Array.from({ length: Math.min(256, 8292 - start) }, (_, i) => start + i) + await k.transaction(async trx => { + await trx('tx_labels').insert( + ids.map(id => ({ ...common, txLabelId: id, userId: id % 2 ? otherId : userId, label: 'large-' + id })) + ) + await trx('output_tags').insert( + ids.map(id => ({ ...common, outputTagId: id, userId: id % 2 ? otherId : userId, tag: 'large-' + id })) + ) + await trx('tx_labels_map').insert(ids.map(id => ({ ...common, txLabelId: id, transactionId: id % 2 ? 2 : 1 }))) + await trx('output_tags_map').insert(ids.map(id => ({ ...common, outputTagId: id, outputId: id % 2 ? 2 : 1 }))) + }) + } + if (mysql) await k.raw('ANALYZE TABLE snapshot_relation_keys, tx_labels_map, output_tags_map') + const view = await source.openReadSnapshot() + const read = pageReader(source, userId, 'large-seek-fixture', view, true, true) + const results = [] + const counters = () => + view.read(async trx => + Object.fromEntries( + (await source.toDb(trx).raw("SHOW SESSION STATUS LIKE 'Handler_read_%'"))[0].map(row => [ + row.Variable_name, + Number(row.Value) + ]) + ) + ) + try { + for (const table of ['txLabelMaps', 'outputTagMaps']) { + await read(table, undefined, { maxRows: 1 }) + for (const leftId of [1500, 7000, 8280]) { + const queries = [] + const listener = q => { + if (q.sql.startsWith('select') && q.sql.includes('snapshot_relation_keys') && q.sql.includes('cross join')) + queries.push(q) + } + const before = mysql ? await counters() : {} + k.on('query', listener) + let page + try { + page = await read( + table, + { version: 1, snapshotId: 'large-seek-fixture', table, after: [leftId, 1] }, + { maxRows: 16, maxBytes: 131072 } + ) + } finally { + k.off('query', listener) + } + const after = mysql ? await counters() : {} + const deltas = Object.fromEntries(Object.keys(after).map(key => [key, after[key] - before[key]])) + const expectedIds = Array.from({ length: Math.min(16, (8290 - leftId) / 2) }, (_, i) => leftId + (i + 1) * 2) + const field = table === 'txLabelMaps' ? 'txLabelId' : 'outputTagId' + assert.deepEqual( + page.rows.map(row => row[field]), + expectedIds + ) + const plans = [] + for (const q of queries) + plans.push( + await view.read(async trx => + mysql + ? (await source.toDb(trx).raw('EXPLAIN ' + q.sql, q.bindings))[0] + : await source.toDb(trx).raw('EXPLAIN QUERY PLAN ' + q.sql, q.bindings) + ) + ) + const result = { table, after: leftId, rows: page.rows.length, deltas, plans, queries } + results.push(result) + if (mysql) { + assert.ok(deltas.Handler_read_next <= 64, 'The page must not scan prior or foreign relation keys') + assert.ok(deltas.Handler_read_rnd_next <= 64, 'The page must not scan rows into a temporary table') + assert.ok( + plans.every(plan => plan[0].table === 'snapshot_relation_keys' && plan[0].type === 'range'), + 'Both page passes must seek the composite profile range' + ) + } + } + } + } finally { + await view.close() + } + return results +} + +async function qualifyMysqlRelationIndexSeeks(control, connection) { + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const database = 'ts569_relation_seeks_' + randomUUID().replaceAll('-', '') + await control.raw('CREATE DATABASE ??', [database]) + const source = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + }) + try { + await source.migrate('synthetic relation seek', 'synthetic-relation-seek') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } + for (const [id, userId] of [ + [1, user.userId], + [2, other.userId] + ]) { + await source + .knex('transactions') + .insert({ + ...dates, + transactionId: id, + userId, + status: 'completed', + reference: 'relation-' + id, + isOutgoing: true, + satoshis: 0, + description: '' + }) + await source + .knex('outputs') + .insert({ + ...dates, + outputId: id, + userId, + transactionId: id, + spendable: false, + change: false, + vout: 0, + satoshis: 1, + providedBy: 'you', + purpose: '', + type: 'P2PKH' + }) + } + return await largeSeeks(source, user.userId, other.userId, true) + } finally { + await source.destroy() + await control.raw('DROP DATABASE ??', [database]) + } +} +module.exports = { qualifyMysqlRelationIndexSeeks } diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotRelationFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotRelationFixtures.ts new file mode 100644 index 000000000..93f3b25c3 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotRelationFixtures.ts @@ -0,0 +1,85 @@ +import { knex, type Knex } from 'knex' +import { runInSeries } from '../../src/utility/runInSeries' + +// Independent fixture definitions and source-derived oracle, rather than the +// migration's relation registry or trigger expressions. +export const relationFixtures = [ + { table: 'tx_labels_map', left: 'tx_labels', leftKey: 'txLabelId', right: 'transactions', rightKey: 'transactionId' }, + { table: 'output_tags_map', left: 'output_tags', leftKey: 'outputTagId', right: 'outputs', rightKey: 'outputId' } +] as const + +export async function minimalRelationDatabase(): Promise { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + try { + await runInSeries(relationFixtures, async relation => { + await runInSeries( + [ + [relation.left, relation.leftKey], + [relation.right, relation.rightKey] + ], + async ([table, key]) => { + await k.schema.createTable(table, t => { + t.integer(key).primary() + t.integer('userId').notNullable() + }) + } + ) + await k.schema.createTable(relation.table, t => { + t.integer(relation.leftKey) + t.integer(relation.rightKey) + t.boolean('isDeleted') + t.primary([relation.leftKey, relation.rightKey]) + t.index(relation.rightKey) + }) + }) + return k + } catch (error) { + await k.destroy() + throw error + } +} + +export async function expectRelationMembership(k: Knex): Promise { + const expected: Array> = [] + await runInSeries(relationFixtures.entries(), async ([tableId, relation]) => { + const left = new Map((await k(relation.left)).map(row => [row[relation.leftKey], row.userId])) + const right = new Map((await k(relation.right)).map(row => [row[relation.rightKey], row.userId])) + for (const row of await k(relation.table)) { + const owners = new Map() + for (const [userId, bit] of [ + [left.get(row[relation.leftKey]), 1], + [right.get(row[relation.rightKey]), 2] + ]) { + if (userId !== undefined && bit !== undefined) owners.set(userId, (owners.get(userId) ?? 0) | bit) + } + for (const [userId, membership] of owners) + expected.push({ + snapshotTableId: tableId, + snapshotUserId: userId, + snapshotLeftId: row[relation.leftKey], + snapshotRightId: row[relation.rightKey], + snapshotMembership: membership + }) + } + }) + expected.sort( + (a, b) => + a.snapshotTableId - b.snapshotTableId || + a.snapshotUserId - b.snapshotUserId || + a.snapshotLeftId - b.snapshotLeftId || + a.snapshotRightId - b.snapshotRightId + ) + expect( + await k('snapshot_relation_keys').orderBy([ + 'snapshotTableId', + 'snapshotUserId', + 'snapshotLeftId', + 'snapshotRightId' + ]) + ).toEqual(expected) +} diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index 1a63da316..442c13b3e 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -22,6 +22,28 @@ const plans = new Map([ ['src/storage/StorageProvider.ts', 'storage'] ]) } + ], + [ + 'wallet-snapshot-remote-http', + { + fallback: 'protocol', + files: new Map([ + ['src/storage/remoting/StorageServer.ts', 'server'], + ['src/storage/remoting/StorageClientBase.ts', 'client'] + ]) + } + ], + [ + 'wallet-snapshot-remote-service', + { + fallback: 'persistence', + files: new Map([ + ['src/storage/snapshot/archive/KnexSnapshotArchiveService.ts', 'controller'], + ['src/storage/snapshot/archive/SnapshotArchiveGuard.ts', 'guard'], + ['src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts', 'guard'], + ['src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts', 'guard'] + ]) + } ] ]) diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 4c48a8b28..7cd37e372 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { parseArguments } from './mutation-testing.mjs' +import { parseArguments, REPOSITORY_ROOT } from './mutation-testing.mjs' +import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' import { partitionMutationTarget, selectedMutationPartition, @@ -142,3 +143,114 @@ test('retained partitions preserve complete lifecycle/reader/storage unions and ) assert.throws(() => partitionedMutationTargets(['sdk-auth-http', 'sdk-auth-http'], targets)) }) + +test('service execution preserves the complete canonical union and all configuration in every part', () => { + const canonical = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-remote-service'] + const parts = partitionMutationTarget('wallet-snapshot-remote-service', canonical) + assert.deepEqual( + parts.map(part => part.id), + ['persistence', 'controller', 'guard'] + ) + assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) + assert.equal(new Set(parts.flatMap(part => part.target.mutate)).size, canonical.mutate.length) + for (const part of parts) { + const { mutate: _partMutate, ...partConfig } = part.target + const { mutate: _canonicalMutate, ...canonicalConfig } = canonical + assert.deepEqual(partConfig, canonicalConfig) + assert.equal(part.target.runnerOptions, canonical.runnerOptions) + } + assert.deepEqual(parts[1].target.mutate, [ + 'src/storage/snapshot/archive/KnexSnapshotArchiveService.ts' + ]) + assert.deepEqual(parts[2].target.mutate, [ + 'src/storage/snapshot/archive/SnapshotArchiveGuard.ts', + 'src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts', + 'src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts' + ]) + const future = { + ...canonical, + mutate: [...canonical.mutate, 'src/storage/snapshot/archive/FutureHelper.ts'] + } + const expanded = partitionMutationTarget('wallet-snapshot-remote-service', future) + assert.equal(expanded[0].id, 'persistence') + assert.ok(expanded[0].target.mutate.includes('src/storage/snapshot/archive/FutureHelper.ts')) + assert.equal(selectedMutationPartition('wallet-snapshot-remote-service', canonical), canonical) + assert.throws(() => + selectedMutationPartition('wallet-snapshot-remote-service', canonical, 'missing') + ) + for (const specification of ['src/**/*.ts', '!src/helper.ts', '../outside.ts', '/outside.ts']) { + assert.throws(() => + partitionMutationTarget('wallet-snapshot-remote-service', { mutate: [specification] }) + ) + } + const targets = { before: target, 'wallet-snapshot-remote-service': canonical, after: target } + assert.deepEqual(mutationExecutionMatrix(Object.keys(targets), targets).include, [ + { target: 'before', partition: 'whole' }, + ...['persistence', 'controller', 'guard'].map(partition => ({ + target: 'wallet-snapshot-remote-service', + partition + })), + { target: 'after', partition: 'whole' } + ]) +}) + +test('HTTP execution preserves every canonical range, full configuration and future fallback', () => { + const canonical = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-remote-http'] + const parts = partitionMutationTarget('wallet-snapshot-remote-http', canonical) + assert.deepEqual( + parts.map(part => part.id), + ['protocol', 'client', 'server'] + ) + const original = [...canonical.mutate].sort() + const actual = parts.flatMap(part => part.target.mutate) + assert.deepEqual([...actual].sort(), original) + assert.equal(new Set(actual).size, original.length) + for (const part of parts) { + const { mutate: _partMutate, ...partConfig } = part.target + const { mutate: _canonicalMutate, ...canonicalConfig } = canonical + assert.deepEqual(partConfig, canonicalConfig) + assert.equal(part.target.runnerOptions, canonical.runnerOptions) + } + assert.deepEqual(parts[0].target.mutate, [ + 'src/storage/snapshot/archive/SnapshotArchiveProtocol.ts', + 'src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts', + 'src/storage/snapshot/archive/SnapshotArchiveTransport.ts' + ]) + for (const [index, file] of [ + [1, 'StorageClientBase.ts'], + [2, 'StorageServer.ts'] + ]) { + assert.deepEqual( + parts[index].target.mutate, + canonical.mutate.filter(specification => + specification.startsWith(`src/storage/remoting/${file}:`) + ) + ) + assert.ok(parts[index].target.mutate.length > 1) + } + const helper = 'src/storage/snapshot/archive/FutureHttpHelper.ts' + const expanded = partitionMutationTarget('wallet-snapshot-remote-http', { + ...canonical, + mutate: [...canonical.mutate, helper] + }) + assert.equal(expanded[0].id, 'protocol') + assert.deepEqual(expanded[0].target.mutate, [...parts[0].target.mutate, helper]) + assert.equal(selectedMutationPartition('wallet-snapshot-remote-http', canonical), canonical) + assert.throws(() => + selectedMutationPartition('wallet-snapshot-remote-http', canonical, 'missing') + ) + for (const specification of ['src/**/*.ts', '!src/helper.ts', '../outside.ts', '/outside.ts']) { + assert.throws(() => + partitionMutationTarget('wallet-snapshot-remote-http', { mutate: [specification] }) + ) + } + const targets = { before: target, 'wallet-snapshot-remote-http': canonical, after: target } + assert.deepEqual(mutationExecutionMatrix(Object.keys(targets), targets).include, [ + { target: 'before', partition: 'whole' }, + ...['protocol', 'client', 'server'].map(partition => ({ + target: 'wallet-snapshot-remote-http', + partition + })), + { target: 'after', partition: 'whole' } + ]) +}) diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs index 428df8644..d91f28df5 100644 --- a/scripts/mutation-testing.mjs +++ b/scripts/mutation-testing.mjs @@ -65,6 +65,9 @@ function targetInputPatterns(target) { for (const mutate of target.mutate ?? []) { patterns.push(`${packageDirectory}/${mutate.replace(/:\d+(?:-\d+)?$/, '')}`) } + for (const input of target.additionalInputs ?? []) { + patterns.push(`${packageDirectory}/${normalized(input)}`) + } if (typeof target.propertyTest === 'string') patterns.push(normalized(target.propertyTest)) const jest = target.runnerOptions?.jest const vitest = target.runnerOptions?.vitest diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 1adf31a8c..040e6e8fa 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -1,10 +1,12 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' import { calculateMutationMetrics, evaluateMutationReport, parseArguments, + REPOSITORY_ROOT, selectAffectedMutationTargets, targetsForUnresolvedMutationRange } from './mutation-testing.mjs' @@ -147,3 +149,74 @@ test('mutation report evaluation ratchets score, coverage, and invalid outcomes' ] ) }) + +test('additional package-relative fixture inputs select their target without replacing existing inputs', () => { + const target = { + packageDirectory: 'packages/one', + propertyTest: 'packages/one/test/value.property.test.ts', + mutate: ['src/value.ts:10-20'], + additionalInputs: ['./test/fixtures/source.ts'], + runnerOptions: { + jest: { + configFile: 'jest.config.cjs', + config: { testMatch: ['/test/value*.test.ts'] } + } + } + } + const precise = { + one: target, + two: { + ...target, + packageDirectory: 'packages/two', + propertyTest: 'packages/two/test/value.property.test.ts' + } + } + for (const input of [ + 'src/value.ts', + 'test/value.property.test.ts', + 'jest.config.cjs', + 'test/value.test.ts', + 'test/fixtures/source.ts' + ]) { + assert.deepEqual(selectAffectedMutationTargets(precise, [`packages/one/${input}`]), ['one']) + assert.deepEqual(selectAffectedMutationTargets(precise, [`packages/two/${input}`]), ['two']) + } + for (const input of [ + 'test/fixtures/other.ts', + 'test/fixtures/source.ts.extra', + 'src/other.ts', + 'packages/one/test/fixtures/source.ts' + ]) { + assert.deepEqual(selectAffectedMutationTargets(precise, [`packages/one/${input}`]), []) + } + assert.deepEqual(selectAffectedMutationTargets(precise, ['test/fixtures/source.ts']), []) + assert.deepEqual( + selectAffectedMutationTargets(precise, ['packages/one/test/fixtures/source.ts'], { + changedTargetIds: ['two'] + }), + ['one', 'two'] + ) + + const canonical = buildMutationTargets(REPOSITORY_ROOT) + assert.equal(Object.keys(canonical).length, 46) + assert.deepEqual(canonical['wallet-retained-snapshot'].additionalInputs, [ + 'test/utils/snapshotRelationFixtures.ts' + ]) + assert.deepEqual( + selectAffectedMutationTargets(canonical, [ + 'packages/wallet/wallet-toolbox/test/utils/snapshotRelationFixtures.ts' + ]), + ['wallet-retained-snapshot'] + ) + for (const input of [ + 'src/storage/schema/snapshotRelationIndexMigration.ts', + 'src/storage/schema/snapshotProfileIndexMigration.ts', + 'src/storage/snapshot/RetainedReadSnapshot.property.test.ts' + ]) { + assert.ok( + selectAffectedMutationTargets(canonical, [ + `packages/wallet/wallet-toolbox/${input}` + ]).includes('wallet-retained-snapshot') + ) + } +}) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 47a0ce2f5..64c193984 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -5,6 +5,17 @@ contract for this program on [#569](https://github.com/bsv-blockchain/ts-stack/p The PR remains open, unmerged and draft while implementation or qualification is incomplete. A green intermediate source revision does not complete this program. +The numeric relation checkpoint extends auxiliary indexed selection to +`tx_labels_map` and `output_tags_map`. It preserves composite order and both parent +ownership bases, retains inconsistent mappings for closure refusal, and resumes +256-row bootstrap batches after interrupted DDL or transaction commits. Retained +ordinary/archive readers choose the complete migration inside their pinned view. +Native fixtures cover seven migrator process-loss boundaries, independent writer +locks under both MySQL isolation levels and late-page range/read-count evidence. +Repository and exact-head qualification must still complete for this checkpoint. +The other three indirect tables, commit ordering, nonblocking IndexedDB and the +remaining program below remain open; this does not complete S2. + ## Baseline and immediate defect At `ec12ee79deb69d2019b3e50ee70d975752293d0b`, `syncFromReaderResumable` yields From ff7903d608eca3486b6eb1f302ca3b2c8710bce6 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 07:15:11 -0700 Subject: [PATCH 073/127] refactor(wallet): preserve serial relation fixture qualification --- .../schema/snapshotRelationIndexMigration.ts | 30 ++-- .../storage/snapshotRelationIndexMysql.cjs | 44 ++--- .../storage/snapshotRelationIndexSeeks.cjs | 151 +++++++++--------- 3 files changed, 119 insertions(+), 106 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts index a288184f3..363401d47 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts @@ -117,21 +117,27 @@ function triggers(isMysql: boolean): { observers: Trigger[]; producers: Trigger[ const changed = `${different(relation.leftKey)} OR ${different(relation.rightKey)}` const remove = removeMap(relation, tableId) const add = addMapSide(isMysql, relation, tableId, 'left') + ' ' + addMapSide(isMysql, relation, tableId, 'right') - observers.push(trigger(isMysql, `${prefix}_map_delete`, relation.table, 'AFTER', 'DELETE', remove)) - observers.push(trigger(isMysql, `${prefix}_map_before_update`, relation.table, 'BEFORE', 'UPDATE', remove, changed)) - producers.push(trigger(isMysql, `${prefix}_map_insert`, relation.table, 'AFTER', 'INSERT', add)) - producers.push(trigger(isMysql, `${prefix}_map_after_update`, relation.table, 'AFTER', 'UPDATE', add, changed)) + observers.push( + trigger(isMysql, `${prefix}_map_delete`, relation.table, 'AFTER', 'DELETE', remove), + trigger(isMysql, `${prefix}_map_before_update`, relation.table, 'BEFORE', 'UPDATE', remove, changed) + ) + producers.push( + trigger(isMysql, `${prefix}_map_insert`, relation.table, 'AFTER', 'INSERT', add), + trigger(isMysql, `${prefix}_map_after_update`, relation.table, 'AFTER', 'UPDATE', add, changed) + ) for (const side of ['left', 'right'] as const) { const { table, key } = sideInfo(relation, side) const ownerChanged = `${different(key)} OR ${different('userId')}` const subtract = removeParent(relation, tableId, side) const append = addParent(isMysql, relation, tableId, side) - observers.push(trigger(isMysql, `${prefix}_${side}_delete`, table, 'AFTER', 'DELETE', subtract)) observers.push( + trigger(isMysql, `${prefix}_${side}_delete`, table, 'AFTER', 'DELETE', subtract), trigger(isMysql, `${prefix}_${side}_before_update`, table, 'BEFORE', 'UPDATE', subtract, ownerChanged) ) - producers.push(trigger(isMysql, `${prefix}_${side}_insert`, table, 'AFTER', 'INSERT', append)) - producers.push(trigger(isMysql, `${prefix}_${side}_after_update`, table, 'AFTER', 'UPDATE', append, ownerChanged)) + producers.push( + trigger(isMysql, `${prefix}_${side}_insert`, table, 'AFTER', 'INSERT', append), + trigger(isMysql, `${prefix}_${side}_after_update`, table, 'AFTER', 'UPDATE', append, ownerChanged) + ) } } return { observers, producers } @@ -238,12 +244,7 @@ async function sqliteTable(k: Knex, table: string, keys: boolean, secondary: boo if (!secondary) return true for (const expectedIndex of indexes) { const found = existing.find(index => index.name === expectedIndex.name) - if ( - found === undefined || - found.unique !== 0 || - found.partial !== 0 || - !(await sqliteIndex(k, found.name, expectedIndex.columns)) - ) + if (found?.unique !== 0 || found.partial !== 0 || !(await sqliteIndex(k, found.name, expectedIndex.columns))) return false } return true @@ -262,7 +263,8 @@ async function mysqlTable(k: Knex, table: string, keys: boolean, secondary: bool columns.length !== expected.length || columns.some((column, i) => { const field = expected[i] - const type = field.type === 'boolean' ? 'tinyint' : 'int' + (field.unsigned === true ? ' unsigned' : '') + const integerType = field.unsigned === true ? 'int unsigned' : 'int' + const type = field.type === 'boolean' ? 'tinyint' : integerType return ( column.name !== field.name || column.type.replaceAll(/\(\d+\)/g, '') !== type || diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexMysql.cjs index a76acae46..a3f055ec8 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexMysql.cjs @@ -16,12 +16,16 @@ function success(result) { if (!result.ok) throw result.error } async function until(check) { - for (let i = 0; i < 1000; i++) { - const result = await check() - if (result) return result - await new Promise(resolve => setTimeout(resolve, 5)) + let result + function* attempts() { + for (let i = 0; i < 1000 && !result; i++) yield i } - throw Error('Native lock observation exceeded five seconds') + await runInSeries(attempts(), async () => { + result = await check() + if (!result) await new Promise(resolve => setTimeout(resolve, 5)) + }) + if (!result) throw new Error('Native lock observation exceeded five seconds') + return result } async function fixture(admin, connection, isolation, relation, tableId) { @@ -34,19 +38,23 @@ async function fixture(admin, connection, isolation, relation, tableId) { const events = [] let transaction try { - for (const client of [k, writer, observer]) + await runInSeries([k, writer, observer], async client => { await client.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation.toUpperCase()) - for (const p of relations) { - for (const [table, key] of [ - [p.left, p.leftKey], - [p.right, p.rightKey] - ]) { - await k.schema.createTable(table, t => { - t.integer(key).primary() - t.integer('userId').notNullable() - }) - await k(table).insert({ [key]: 1, userId: 1 }) - } + }) + await runInSeries(relations, async p => { + await runInSeries( + [ + [p.left, p.leftKey], + [p.right, p.rightKey] + ], + async ([table, key]) => { + await k.schema.createTable(table, t => { + t.integer(key).primary() + t.integer('userId').notNullable() + }) + await k(table).insert({ [key]: 1, userId: 1 }) + } + ) await k.schema.createTable(p.table, t => { t.integer(p.leftKey) t.integer(p.rightKey) @@ -54,7 +62,7 @@ async function fixture(admin, connection, isolation, relation, tableId) { t.primary([p.leftKey, p.rightKey]) t.index(p.rightKey) }) - } + }) await install(k) const p = relation const mapping = { [p.leftKey]: 1, [p.rightKey]: 1, isDeleted: false } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs index 6bf9fd60e..a7bc23670 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs @@ -4,14 +4,16 @@ const { randomUUID } = require('node:crypto') const { knex } = require('knex') const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') const { createKnexWalletSnapshotPageReader: pageReader } = require('../../out/src/storage/snapshot/KnexWalletReadSnapshot.js') -async function largeSeeks(source, userId, otherId, mysql) { - const k = source.knex - const date = mysql ? new Date('2026-01-01T00:00:00Z') : '2026-01-01T00:00:00Z' + +async function seedLargeRelations(k, userId, otherId) { + const date = new Date('2026-01-01T00:00:00Z') const common = { created_at: date, updated_at: date, isDeleted: false } - for (let start = 100; start < 8292; start += 256) { + const starts = Array.from({ length: 32 }, (_, index) => 100 + index * 256) + await runInSeries(starts, async start => { const ids = Array.from({ length: Math.min(256, 8292 - start) }, (_, i) => start + i) await k.transaction(async trx => { await trx('tx_labels').insert( @@ -23,70 +25,72 @@ async function largeSeeks(source, userId, otherId, mysql) { await trx('tx_labels_map').insert(ids.map(id => ({ ...common, txLabelId: id, transactionId: id % 2 ? 2 : 1 }))) await trx('output_tags_map').insert(ids.map(id => ({ ...common, outputTagId: id, outputId: id % 2 ? 2 : 1 }))) }) + }) + await k.raw('ANALYZE TABLE snapshot_relation_keys, tx_labels_map, output_tags_map') +} + +async function handlerCounters(source, view) { + return await view.read(async trx => + Object.fromEntries( + (await source.toDb(trx).raw("SHOW SESSION STATUS LIKE 'Handler_read_%'"))[0].map(row => [ + row.Variable_name, + Number(row.Value) + ]) + ) + ) +} + +async function observePage(source, view, read, table, leftId) { + const queries = [] + const listener = q => { + if (q.sql.startsWith('select') && q.sql.includes('snapshot_relation_keys') && q.sql.includes('cross join')) + queries.push(q) + } + const before = await handlerCounters(source, view) + source.knex.on('query', listener) + let page + try { + page = await read( + table, + { version: 1, snapshotId: 'large-seek-fixture', table, after: [leftId, 1] }, + { maxRows: 16, maxBytes: 131072 } + ) + } finally { + source.knex.off('query', listener) } - if (mysql) await k.raw('ANALYZE TABLE snapshot_relation_keys, tx_labels_map, output_tags_map') + const after = await handlerCounters(source, view) + const deltas = Object.fromEntries(Object.keys(after).map(key => [key, after[key] - before[key]])) + const expectedIds = Array.from({ length: Math.min(16, (8290 - leftId) / 2) }, (_, i) => leftId + (i + 1) * 2) + const field = table === 'txLabelMaps' ? 'txLabelId' : 'outputTagId' + assert.deepEqual( + page.rows.map(row => row[field]), + expectedIds + ) + const plans = [] + await runInSeries(queries, async q => { + plans.push(await view.read(async trx => (await source.toDb(trx).raw('EXPLAIN ' + q.sql, q.bindings))[0])) + }) + assert.ok(deltas.Handler_read_next <= 64, 'The page must not scan prior or foreign relation keys') + assert.ok(deltas.Handler_read_rnd_next <= 64, 'The page must not scan rows into a temporary table') + assert.ok( + plans.every(plan => plan[0].table === 'snapshot_relation_keys' && plan[0].type === 'range'), + 'Both page passes must seek the composite profile range' + ) + return { table, after: leftId, rows: page.rows.length, deltas, plans, queries } +} + +async function largeSeeks(source, userId, otherId) { + await seedLargeRelations(source.knex, userId, otherId) const view = await source.openReadSnapshot() const read = pageReader(source, userId, 'large-seek-fixture', view, true, true) const results = [] - const counters = () => - view.read(async trx => - Object.fromEntries( - (await source.toDb(trx).raw("SHOW SESSION STATUS LIKE 'Handler_read_%'"))[0].map(row => [ - row.Variable_name, - Number(row.Value) - ]) - ) - ) try { - for (const table of ['txLabelMaps', 'outputTagMaps']) { + await runInSeries(['txLabelMaps', 'outputTagMaps'], async table => { await read(table, undefined, { maxRows: 1 }) - for (const leftId of [1500, 7000, 8280]) { - const queries = [] - const listener = q => { - if (q.sql.startsWith('select') && q.sql.includes('snapshot_relation_keys') && q.sql.includes('cross join')) - queries.push(q) - } - const before = mysql ? await counters() : {} - k.on('query', listener) - let page - try { - page = await read( - table, - { version: 1, snapshotId: 'large-seek-fixture', table, after: [leftId, 1] }, - { maxRows: 16, maxBytes: 131072 } - ) - } finally { - k.off('query', listener) - } - const after = mysql ? await counters() : {} - const deltas = Object.fromEntries(Object.keys(after).map(key => [key, after[key] - before[key]])) - const expectedIds = Array.from({ length: Math.min(16, (8290 - leftId) / 2) }, (_, i) => leftId + (i + 1) * 2) - const field = table === 'txLabelMaps' ? 'txLabelId' : 'outputTagId' - assert.deepEqual( - page.rows.map(row => row[field]), - expectedIds - ) - const plans = [] - for (const q of queries) - plans.push( - await view.read(async trx => - mysql - ? (await source.toDb(trx).raw('EXPLAIN ' + q.sql, q.bindings))[0] - : await source.toDb(trx).raw('EXPLAIN QUERY PLAN ' + q.sql, q.bindings) - ) - ) - const result = { table, after: leftId, rows: page.rows.length, deltas, plans, queries } - results.push(result) - if (mysql) { - assert.ok(deltas.Handler_read_next <= 64, 'The page must not scan prior or foreign relation keys') - assert.ok(deltas.Handler_read_rnd_next <= 64, 'The page must not scan rows into a temporary table') - assert.ok( - plans.every(plan => plan[0].table === 'snapshot_relation_keys' && plan[0].type === 'range'), - 'Both page passes must seek the composite profile range' - ) - } - } - } + await runInSeries([1500, 7000, 8280], async leftId => { + results.push(await observePage(source, view, read, table, leftId)) + }) + }) } finally { await view.close() } @@ -108,13 +112,13 @@ async function qualifyMysqlRelationIndexSeeks(control, connection) { const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } - for (const [id, userId] of [ - [1, user.userId], - [2, other.userId] - ]) { - await source - .knex('transactions') - .insert({ + await runInSeries( + [ + [1, user.userId], + [2, other.userId] + ], + async ([id, userId]) => { + await source.knex('transactions').insert({ ...dates, transactionId: id, userId, @@ -124,9 +128,7 @@ async function qualifyMysqlRelationIndexSeeks(control, connection) { satoshis: 0, description: '' }) - await source - .knex('outputs') - .insert({ + await source.knex('outputs').insert({ ...dates, outputId: id, userId, @@ -139,8 +141,9 @@ async function qualifyMysqlRelationIndexSeeks(control, connection) { purpose: '', type: 'P2PKH' }) - } - return await largeSeeks(source, user.userId, other.userId, true) + } + ) + return await largeSeeks(source, user.userId, other.userId) } finally { await source.destroy() await control.raw('DROP DATABASE ??', [database]) From 8f4a7311beb169825878176f07b0caeac86f3f91 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 12:24:17 -0700 Subject: [PATCH 074/127] Fix wallet mutation qualification inputs and traversal regressions --- .github/workflows/ci.yml | 20 +++++++ docs/reference/test-quality-governance.md | 23 +++++++- .../snapshot/KnexWalletReadSnapshot.test.ts | 24 ++++++--- .../archive/KnexSnapshotArchiveRpc.test.ts | 24 +++++++++ .../SnapshotArchiveReaderClient.test.ts | 8 +-- .../archive/SnapshotArchiveTransport.test.ts | 16 ++++++ scripts/ci-orchestration.test.mjs | 32 +++++++++++ scripts/mutation-partitions.mjs | 29 +++++++++- scripts/mutation-partitions.test.mjs | 53 +++++++++++++++++-- 9 files changed, 211 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54de791a2..ccfc4af89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -607,6 +607,26 @@ jobs: with: pattern: mutation-wallet-retained-snapshot-* path: .mutation-parts/wallet-retained-snapshot + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-snapshot-remote-http') + with: + pattern: mutation-wallet-snapshot-remote-http-* + path: .mutation-parts/wallet-snapshot-remote-http + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-snapshot-remote-service') + with: + pattern: mutation-wallet-snapshot-remote-service-* + path: .mutation-parts/wallet-snapshot-remote-service + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-snapshot-archive') + with: + pattern: mutation-wallet-snapshot-archive-* + path: .mutation-parts/wallet-snapshot-archive + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-snapshot-remote-reader') + with: + pattern: mutation-wallet-snapshot-remote-reader-* + path: .mutation-parts/wallet-snapshot-remote-reader - name: Require every selected canonical partition target gate if: needs.prepare.outputs.partition-targets != '[]' && needs.prepare.outputs.partition-targets != '' env: diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 0eba8c9fa..b98219254 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -218,14 +218,33 @@ started 1,357 mutants after 266 passing dry-run tests. Neither produced a comple report. Their execution partitions keep every canonical specification exactly once. HTTP places all server ranges together, all client ranges together, and the whole protocol/RPC/transport sources in the protocol fallback. Service places -the whole controller in one part, the three whole guard modules in another, and -all other sources in the persistence fallback. Future canonical files join the +the whole controller in one part, guard/registry in another, the whole guard +backend separately, and all other sources in the persistence fallback. +Future canonical files join the fallback automatically. Every execution part keeps the full original tests, property suite, input dependencies and runner configuration. The existing provenance and aggregate checks require all parts from the same source and configuration, then evaluate the complete canonical mutant union; scores are not averaged. These execution parts do not create new canonical targets. +The [next complete run](https://github.com/bsv-blockchain/ts-stack/actions/runs/36874901071) +also reached the 90-minute limit for retained reader/lifecycle, archive, remote +reader and service guard execution. Retained profile and relation migrations now +each execute as a whole-file part, alongside reader, storage and the lifecycle +fallback. Archive groups store/migration, source/closure and the capture fallback. +Remote reader groups lease, rows, page/open/cursor and the admission fallback. +These partitions retain every original source specification and full test +configuration. The single-file retained reader remains one complete part; +its traversal tests assert fixture bounds and cursor progress so broken paging +fails promptly. Partitioning and test changes require fresh complete evidence; +the cancelled run does not qualify these targets. + +PR CI downloads each selected target's complete partition artifacts before +canonical verification. The orchestration regression derives the required +downloads from the canonical target registry and execution map, preventing a +new partitioned target from being omitted. Scheduled/manual qualification uses +its existing target matrix to download and independently verify the same union. + Both workflows use a 45-minute default and the same explicit 90-minute target allowance list, including retained snapshots, snapshot sync, archive, remote HTTP, remote reader and remote service. The execution split does not change diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts index ac963e089..e17619abf 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts @@ -71,15 +71,23 @@ async function labels(source: StorageKnex, userId: number, count: number): Promi async function all( view: WalletReadSnapshot, - table: T + table: T, + expectedMaximumRows: number ): Promise>> { const rows: Array> = [] + const positions = new Set() let cursor: WalletSnapshotCursor | undefined for (;;) { const page = await view.readPage(table, cursor, { maxRows: 1 }) + expect(page.rows.length).toBeLessThanOrEqual(1) rows.push(...page.rows) + expect(rows.length).toBeLessThanOrEqual(expectedMaximumRows) if (page.done) return rows + expect(page.rows).toHaveLength(1) expect(page.cursor).toBeDefined() + const position = JSON.stringify(page.cursor!.after) + expect(positions.has(position)).toBe(false) + positions.add(position) cursor = page.cursor } } @@ -144,7 +152,7 @@ test('keyset pages pin profile, source metadata, equal timestamps and tombstones view.user.userId = otherId const next = await view.readPage('txLabels', first.cursor, { maxRows: 3 }) expect(next.rows).toEqual(expected.slice(3, 6)) - expect(await all(view, 'txLabels')).toEqual(expected) + expect(await all(view, 'txLabels', expected.length)).toEqual(expected) await view.close() await view.closed expect(view.isOpen).toBe(false) @@ -152,7 +160,7 @@ test('keyset pages pin profile, source metadata, equal timestamps and tombstones const fresh = await source.openWalletReadSnapshot(identity) expect(fresh.user.activeStorage).toBe('new primary') await expect(fresh.readPage('txLabels', first.cursor)).rejects.toThrow('cursor') - expect(await all(fresh, 'txLabels')).not.toEqual(expected) + expect(await all(fresh, 'txLabels', expected.length)).not.toEqual(expected) await fresh.close() }) @@ -406,7 +414,7 @@ test('all thirteen tables retain original rows, packed binary and composite key ['syncStates', 'syncStateId', [1, 3]] ] for (const [table, key, ids] of cases) { - const rows = (await all(view, table)) as unknown as Array> + const rows = (await all(view, table, ids.length)) as unknown as Array> expect(rows.map(row => row[key])).toEqual(ids) for (const row of rows) { expect(row.created_at).toEqual(new Date(date)) @@ -434,17 +442,17 @@ test('all thirteen tables retain original rows, packed binary and composite key } } } - expect((await all(view, 'txLabelMaps')).map(row => [row.txLabelId, row.transactionId, row.isDeleted])).toEqual([ + expect((await all(view, 'txLabelMaps', 3)).map(row => [row.txLabelId, row.transactionId, row.isDeleted])).toEqual([ [1, 1, false], [1, 3, true], [3, 3, true] ]) - expect((await all(view, 'outputTagMaps')).map(row => [row.outputTagId, row.outputId, row.isDeleted])).toEqual([ + expect((await all(view, 'outputTagMaps', 3)).map(row => [row.outputTagId, row.outputId, row.isDeleted])).toEqual([ [1, 1, false], [1, 3, true], [3, 3, true] ]) - expect((await all(view, 'certificateFields')).map(row => [row.fieldName, row.certificateId])).toEqual( + expect((await all(view, 'certificateFields', 8)).map(row => [row.fieldName, row.certificateId])).toEqual( ['Z', 'a', 'é', '😀'].flatMap(field => [ [field, 1], [field, 3] @@ -491,7 +499,7 @@ test('certificate keys preserve empty, embedded-NUL and 100-code-point names wit masterKey: 'key' }) const view = await source.openWalletReadSnapshot(identity) - const rows = await all(view, 'certificateFields') + const rows = await all(view, 'certificateFields', 12) for (const name of names) expect(rows.some(row => row.fieldName === name)).toBe(true) expect(rows).toHaveLength(12) for (const fieldName of [100, 'a'.repeat(101), '😀'.repeat(101)]) { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts index e336345dd..ad314f161 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveRpc.test.ts @@ -8,6 +8,30 @@ import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' afterEach(() => jest.restoreAllMocks()) +test('reader admission capacity refusal returns the exact resource-limited offer without opening a capture', async () => { + const fixture = await snapshotHttpFixture() + const rpc = new KnexSnapshotArchiveRpc(fixture.storage) + try { + const service = Reflect.get(rpc, 'service') as KnexSnapshotArchiveService + const issue = jest.spyOn(service, 'offerReader').mockResolvedValue(undefined) + const open = jest.spyOn(fixture.storage, 'openSnapshotArchiveSource') + const options = { lifetimeMs: 300000, maxBytes: 32768 } + expect( + await rpc.dispatch( + 'getSnapshotArchiveReaderOffer', + [{ version: 1, identityKey: fixture.identityKey, options }], + fixture.identityKey + ) + ).toEqual({ version: 1, outcome: 'resource-limited' }) + expect(issue).toHaveBeenCalledTimes(1) + expect(issue).toHaveBeenCalledWith(fixture.identityKey, options) + expect(open).not.toHaveBeenCalled() + } finally { + await rpc.close() + await fixture.close() + } +}) + test.each([Error, WERR_INVALID_OPERATION])( 'a %p cancellation failure cannot impersonate a pending receipt', async ErrorType => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderClient.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderClient.test.ts index b52d24e2d..27b48246b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderClient.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveReaderClient.test.ts @@ -39,11 +39,11 @@ test('a source adapter exists before availability and negotiates the first open client.settings!.snapshotArchiveReaderVersion = undefined expect((await client.getSnapshotArchiveTransport(identityKey))!.supportsReader).toBe(true) for (const operation of [ - source.begin(undefined as never, undefined), - source.checkpoint('', ''), - source.prepare(undefined as never, undefined as never) + () => source.begin(undefined as never, undefined), + () => source.checkpoint('', ''), + () => source.prepare(undefined as never, undefined as never) ]) - await expect(operation).rejects.toThrow('Remote snapshot destination is unavailable') + await expect(operation()).rejects.toThrow('Remote snapshot destination is unavailable') expect(client.request).toHaveBeenCalledTimes(1) }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.test.ts index 19689d51a..fd1cb8ba7 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveTransport.test.ts @@ -1,5 +1,6 @@ import { SnapshotArchiveTransport } from './SnapshotArchiveTransport' import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' +import { snapshotArchiveReaderRequestId } from './SnapshotArchiveReaderRequest' import { fixture, expected, now } from '../../../../test/utils/snapshotArchiveDirectoryFixtures' const fields = { version: 1 as const, nonce: 'a'.repeat(64), notAfter: now + 1000, maxBytes: 32768 } @@ -36,6 +37,21 @@ const source = () => { return { transport, rpc, ready, offer, directory, payloads } } +test.each(['readerOffer', 'admit', 'readerStatus', 'cancelRequest'] as const)( + 'an archive-only transport refuses %s before network I/O with the negotiated-capability error', + async method => { + const { transport, rpc } = source() + const readerFields = { ...fields, version: 2 as const } + const readerRequest = { ...readerFields, requestId: snapshotArchiveReaderRequestId(readerFields) } + const operation = + method === 'readerOffer' + ? transport.readerOffer({ lifetimeMs: 1000, maxBytes: 32768 }) + : transport[method](readerRequest) + await expect(operation).rejects.toThrow(new TypeError('Snapshot archive reader was not negotiated')) + expect(rpc).not.toHaveBeenCalled() + } +) + test('auth transport binds exact immutable request/root/profile and forwards cancellation for each operation', async () => { const { transport, rpc, ready, offer, payloads } = source() const signal = new AbortController().signal diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 0c2093e9a..f2fcf83b6 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -5,6 +5,8 @@ import { join } from 'node:path' import test from 'node:test' import { REPOSITORY_ROOT } from './repository-health.mjs' +import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' +import { partitionedMutationTargets } from './mutation-partitions.mjs' const CI_PATH = join(REPOSITORY_ROOT, '.github/workflows/ci.yml') const MUTATION_PATH = join(REPOSITORY_ROOT, '.github/workflows/mutation-tests.yml') @@ -32,6 +34,36 @@ function assertWalletMutationTimeout(job, defaultMinutes) { assert.equal(job.source.match(/^ timeout-minutes: .+$/m)?.[0], expected) } +test('CI downloads every canonical execution partition before verifying its complete union', () => { + const targets = buildMutationTargets(REPOSITORY_ROOT) + const partitioned = partitionedMutationTargets(Object.keys(targets), targets) + assert.ok(partitioned.length > 0) + const workflow = readFileSync(CI_PATH, 'utf8') + const gate = workflowJobBlocks(workflow).find(job => job.name === 'mutation-quality').source + const verify = gate.indexOf( + ' - name: Require every selected canonical partition target gate' + ) + assert.ok(verify > 0) + const downloads = gate + .slice(0, verify) + .split(/^ - /m) + .filter(step => step.startsWith('uses: actions/download-artifact@')) + for (const target of partitioned) { + const selected = downloads.filter(step => + step.includes(` path: .mutation-parts/${target}\n`) + ) + assert.equal(selected.length, 1, `${target} requires exactly one artifact download`) + assert.ok( + selected[0].includes( + ` if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), '${target}')\n` + ) + ) + assert.ok(selected[0].includes(` pattern: mutation-${target}-*\n`)) + assert.match(selected[0], /^uses: actions\/download-artifact@[a-f0-9]{40} /) + } + assert.match(gate.slice(verify), /--target "\$target" --directory "\.mutation-parts\/\$target"/) +}) + test('CI shares one audited build across coverage and browser consumer lanes', () => { const workflow = readFileSync(CI_PATH, 'utf8') diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index 442c13b3e..d78982418 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -18,11 +18,25 @@ const plans = new Map([ fallback: 'lifecycle', files: new Map([ ['src/storage/snapshot/KnexWalletReadSnapshot.ts', 'reader'], + ['src/storage/schema/snapshotProfileIndexMigration.ts', 'profile-index'], + ['src/storage/schema/snapshotRelationIndexMigration.ts', 'relation-index'], ['src/storage/StorageKnex.ts', 'storage'], ['src/storage/StorageProvider.ts', 'storage'] ]) } ], + [ + 'wallet-snapshot-archive', + { + fallback: 'capture', + files: new Map([ + ['src/storage/snapshot/archive/KnexSnapshotArchiveStore.ts', 'store'], + ['src/storage/schema/snapshotArchiveMigration.ts', 'store'], + ['src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts', 'source'], + ['src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts', 'source'] + ]) + } + ], [ 'wallet-snapshot-remote-http', { @@ -41,7 +55,20 @@ const plans = new Map([ ['src/storage/snapshot/archive/KnexSnapshotArchiveService.ts', 'controller'], ['src/storage/snapshot/archive/SnapshotArchiveGuard.ts', 'guard'], ['src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts', 'guard'], - ['src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts', 'guard'] + ['src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts', 'backend'] + ]) + } + ], + [ + 'wallet-snapshot-remote-reader', + { + fallback: 'admission', + files: new Map([ + ['src/storage/snapshot/archive/RemoteSnapshotLease.ts', 'lease'], + ['src/storage/snapshot/archive/RemoteSnapshotRows.ts', 'rows'], + ['src/storage/snapshot/archive/RemoteSnapshotPageReader.ts', 'page'], + ['src/storage/snapshot/archive/openRemoteSnapshot.ts', 'page'], + ['src/storage/snapshot/SnapshotCursor.ts', 'page'] ]) } ] diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 7cd37e372..e9488ed7c 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -149,7 +149,7 @@ test('service execution preserves the complete canonical union and all configura const parts = partitionMutationTarget('wallet-snapshot-remote-service', canonical) assert.deepEqual( parts.map(part => part.id), - ['persistence', 'controller', 'guard'] + ['persistence', 'controller', 'guard', 'backend'] ) assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) assert.equal(new Set(parts.flatMap(part => part.target.mutate)).size, canonical.mutate.length) @@ -164,7 +164,9 @@ test('service execution preserves the complete canonical union and all configura ]) assert.deepEqual(parts[2].target.mutate, [ 'src/storage/snapshot/archive/SnapshotArchiveGuard.ts', - 'src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts', + 'src/storage/snapshot/archive/SnapshotArchiveGuardRegistry.ts' + ]) + assert.deepEqual(parts[3].target.mutate, [ 'src/storage/snapshot/archive/SnapshotArchiveGuardBackend.ts' ]) const future = { @@ -186,7 +188,7 @@ test('service execution preserves the complete canonical union and all configura const targets = { before: target, 'wallet-snapshot-remote-service': canonical, after: target } assert.deepEqual(mutationExecutionMatrix(Object.keys(targets), targets).include, [ { target: 'before', partition: 'whole' }, - ...['persistence', 'controller', 'guard'].map(partition => ({ + ...['persistence', 'controller', 'guard', 'backend'].map(partition => ({ target: 'wallet-snapshot-remote-service', partition })), @@ -254,3 +256,48 @@ test('HTTP execution preserves every canonical range, full configuration and fut { target: 'after', partition: 'whole' } ]) }) + +for (const [id, expected, fallback] of [ + [ + 'wallet-retained-snapshot', + ['lifecycle', 'reader', 'profile-index', 'relation-index', 'storage'], + 'lifecycle' + ], + ['wallet-snapshot-archive', ['store', 'capture', 'source'], 'capture'], + ['wallet-snapshot-remote-reader', ['admission', 'lease', 'rows', 'page'], 'admission'] +]) { + test(`${id} preserves canonical whole-file ownership, configuration and future source coverage`, () => { + const canonical = buildMutationTargets(REPOSITORY_ROOT)[id] + const parts = partitionMutationTarget(id, canonical) + assert.deepEqual( + parts.map(part => part.id), + expected + ) + assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) + const owners = new Map() + for (const part of parts) { + const { mutate, ...configuration } = part.target + const { mutate: _canonicalMutate, ...canonicalConfiguration } = canonical + assert.deepEqual(configuration, canonicalConfiguration) + assert.equal(part.target.runnerOptions, canonical.runnerOptions) + for (const specification of mutate) { + const file = specification.replace(/:\d+(?:-\d+)?$/, '') + assert.ok(!owners.has(file) || owners.get(file) === part.id) + owners.set(file, part.id) + } + } + const helper = 'src/storage/snapshot/FutureHelper.ts' + const extended = { ...canonical, mutate: [...canonical.mutate, helper, `${helper}:1-20`] } + const expanded = partitionMutationTarget(id, extended) + assert.deepEqual( + expanded.flatMap(part => part.target.mutate).sort(), + [...extended.mutate].sort() + ) + assert.deepEqual(expanded.find(part => part.id === fallback).target.mutate.slice(-2), [ + helper, + `${helper}:1-20` + ]) + assert.equal(selectedMutationPartition(id, canonical), canonical) + assert.throws(() => selectedMutationPartition(id, canonical, 'missing')) + }) +} From eff7ee19d056f766d34959d7a7c40fa47d075381 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 13:06:50 -0700 Subject: [PATCH 075/127] Bound MySQL relation pages before optimizer statistics refresh --- docs/guides/wallet-sync-reliability.md | 6 +++- docs/reference/package-api-migrations.md | 2 +- governance/package-release-notes.json | 2 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 3 +- packages/wallet/wallet-toolbox/README.md | 3 +- .../KnexWalletReadSnapshot.mysql.test.ts | 4 ++- .../snapshot/KnexWalletReadSnapshot.ts | 7 ++++- .../storage/snapshotRelationIndexSeeks.cjs | 29 +++++++++++-------- specs/wallet/sync-portability-program.md | 3 +- 9 files changed, 39 insertions(+), 20 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index b25e3b49d..785613d75 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -287,6 +287,9 @@ locking reads even if their writer transaction has an older consistent read view SQLite uses its writer lock. Standard tables and their indexes are unchanged. MySQL page queries explicitly select the auxiliary primary index: a maintenance index can otherwise scan and sort an entire profile before applying the page limit. +The query also reads the recorded membership and fixes the join order and indexed +source lookup. A covering-index plan can otherwise scan the prior prefix before +InnoDB refreshes statistics, even when ordinary EXPLAIN reports a range. The same migration-journal, retained-view, interrupted-migrator recovery and reader-drain requirements described above apply. The migration has @@ -302,7 +305,8 @@ Repository fixtures compare all thirteen ordinary/archive tables and legacy offsets before and after indexing, generate ownership/rekey schedules, and kill the real migrator at seven DDL/bootstrap boundaries on SQLite and MySQL. The native MySQL fixture observes independent locks under READ COMMITTED and -REPEATABLE READ and checks late-page row-read counts with interleaved profiles. +REPEATABLE READ and checks late-page native row-read counts with interleaved +profiles both before and after ANALYZE TABLE. These are isolated synthetic fixtures, not deployed PXC or physical mobile qualification. Certificate fields, proof requests/proofs, source commit ordering, nonblocking IndexedDB, streaming and staged restore remain required. Reader diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 964744568..66a524df8 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -514,7 +514,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. - Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 002e39fcc..ab9b17685 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,7 +210,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged.", + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged.", "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled." }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 69b8838d1..88fbacb64 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -11,7 +11,8 @@ attention to changes that materially alter behavior or extend functionality. Preserve both parent ownership bases through moves, rekeys, tombstones and deletion; retain inconsistent relationships for explicit closure refusal. Resume bounded bootstrap after interrupted DDL/transactions and use the - matching auxiliary primary index for MySQL page seeks. The remaining indirect + matching auxiliary primary index, recorded membership and indexed source + lookups for bounded MySQL pages before and after statistics refresh. The remaining indirect tables and full sync/portability program are incomplete. - Add an exact-claim source-owner fence and additive owner migration. Remote diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 20a9d2d53..a7f27d739 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -112,7 +112,8 @@ view. See the [migration and recovery contract](https://bsv-blockchain.github.io The separate numeric relation migration indexes label/tag maps while retaining both parent ownership bases, tombstones and cross-profile inconsistency checks. It preserves composite cursor and legacy OFFSET order, resumes bounded bootstrap -after interruption, and pins MySQL paging to the auxiliary primary index. See its +after interruption, and bounds MySQL paging before and after statistics refresh +with the auxiliary primary index and indexed source lookups. See its [migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-numeric-relation-indexes-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts index 5e5edd3e9..b570bef03 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts @@ -147,7 +147,9 @@ test.each([ expect(query.sql).toContain( '`snapshotLeftId` = `' + name + '`.`' + left + '` and `snapshotRightId` = `' + name + '`.`' + right + '`' ) - expect(query.bindings).toEqual([tableId, 41]) + expect(query.sql).toContain(`/*+ JOIN_FIXED_ORDER() JOIN_INDEX(${name}) */`) + expect(query.sql).toContain('`snapshotMembership` between ? and ?') + expect(query.bindings).toEqual([tableId, 41, 1, 3]) const legacy = walletSnapshotSourceQuery(k, table, 41).toSQL() expect(legacy.sql).not.toContain('snapshot_relation_keys') expect(legacy.sql).toContain('or exists') diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index 2f81666f6..03cfce292 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -153,11 +153,16 @@ export function walletSnapshotSourceQuery( const relationKeys = String(k.client.config.client).includes('mysql') ? k.raw('?? FORCE INDEX (??)', ['snapshot_relation_keys', 'PRIMARY']) : 'snapshot_relation_keys' - return k(relationKeys) + const query = k(relationKeys) .crossJoin(name, function () { void this.on('snapshotLeftId', '=', `${name}.${left}`).andOn('snapshotRightId', '=', `${name}.${right}`) }) .where({ snapshotTableId: relationId, snapshotUserId: userId }) + // Read the recorded membership and keep source lookups indexed even before + // InnoDB has refreshed cardinality statistics after bootstrap or bulk writes. + if (String(k.client.config.client).includes('mysql')) + void query.whereBetween('snapshotMembership', [1, 3]).hintComment(['JOIN_FIXED_ORDER()', `JOIN_INDEX(${name})`]) + return query } const query = k(name) if (table === 'provenTxReqs') { diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs index a7bc23670..16e30af11 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexSeeks.cjs @@ -26,7 +26,6 @@ async function seedLargeRelations(k, userId, otherId) { await trx('output_tags_map').insert(ids.map(id => ({ ...common, outputTagId: id, outputId: id % 2 ? 2 : 1 }))) }) }) - await k.raw('ANALYZE TABLE snapshot_relation_keys, tx_labels_map, output_tags_map') } async function handlerCounters(source, view) { @@ -81,19 +80,25 @@ async function observePage(source, view, read, table, leftId) { async function largeSeeks(source, userId, otherId) { await seedLargeRelations(source.knex, userId, otherId) - const view = await source.openReadSnapshot() - const read = pageReader(source, userId, 'large-seek-fixture', view, true, true) const results = [] - try { - await runInSeries(['txLabelMaps', 'outputTagMaps'], async table => { - await read(table, undefined, { maxRows: 1 }) - await runInSeries([1500, 7000, 8280], async leftId => { - results.push(await observePage(source, view, read, table, leftId)) + // A statistics refresh can hide a full-prefix scan after bootstrap or bulk + // writes. Prove the same bounded native work on both sides of that boundary. + await runInSeries(['fresh', 'refreshed'], async statistics => { + if (statistics === 'refreshed') + await source.knex.raw('ANALYZE TABLE snapshot_relation_keys, tx_labels_map, output_tags_map') + const view = await source.openReadSnapshot() + const read = pageReader(source, userId, 'large-seek-fixture', view, true, true) + try { + await runInSeries(['txLabelMaps', 'outputTagMaps'], async table => { + await read(table, undefined, { maxRows: 1 }) + await runInSeries([1500, 7000, 8280], async leftId => { + results.push({ statistics, ...(await observePage(source, view, read, table, leftId)) }) + }) }) - }) - } finally { - await view.close() - } + } finally { + await view.close() + } + }) return results } diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 64c193984..e5957cdaa 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -11,7 +11,8 @@ ownership bases, retains inconsistent mappings for closure refusal, and resumes 256-row bootstrap batches after interrupted DDL or transaction commits. Retained ordinary/archive readers choose the complete migration inside their pinned view. Native fixtures cover seven migrator process-loss boundaries, independent writer -locks under both MySQL isolation levels and late-page range/read-count evidence. +locks under both MySQL isolation levels and late-page range/read-count evidence +both before and after native optimizer statistics refresh. Repository and exact-head qualification must still complete for this checkpoint. The other three indirect tables, commit ordering, nonblocking IndexedDB and the remaining program below remain open; this does not complete S2. From 256c36210619abf1d8784de492a316dba7a523a0 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 13:40:49 -0700 Subject: [PATCH 076/127] Add resumable certificate field indexes and retained reader integration --- docs/guides/wallet-sync-reliability.md | 44 ++ docs/reference/package-api-migrations.md | 74 +-- docs/reference/test-quality-governance.md | 4 +- governance/mutation-testing/targets.mjs | 6 +- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 6 + packages/wallet/wallet-toolbox/README.md | 7 +- .../src/storage/schema/KnexMigrations.ts | 12 + .../snapshotCertificateIndexMigration.ts | 612 ++++++++++++++++++ .../ConcurrentSnapshotArchiveSource.test.ts | 2 +- .../KnexWalletReadSnapshot.mysql.test.ts | 19 + .../snapshot/KnexWalletReadSnapshot.test.ts | 33 +- .../snapshot/KnexWalletReadSnapshot.ts | 61 +- ...shotCertificateIndexes.integration.test.ts | 239 +++++++ .../SnapshotCertificateIndexes.mysql.test.ts | 360 +++++++++++ .../SnapshotCertificateIndexes.test.ts | 303 +++++++++ .../SnapshotProfileIndexes.migration.test.ts | 4 +- .../KnexSnapshotArchiveCapture.test.ts | 8 +- .../archive/KnexSnapshotArchiveClosure.ts | 12 +- .../archive/KnexSnapshotArchiveSource.ts | 27 +- .../archive/KnexSnapshotArchiveStore.test.ts | 2 +- .../archive/SnapshotArchiveHttp.test.ts | 2 +- .../test/storage/snapshotArchiveCrash.cjs | 2 + .../test/storage/snapshotArchiveMysql.cjs | 16 +- .../storage/snapshotCertificateIndexCrash.cjs | 256 ++++++++ .../storage/snapshotCertificateIndexMysql.cjs | 229 +++++++ ...snapshotCertificateIndexMysqlSchedules.cjs | 169 +++++ .../storage/snapshotCertificateIndexSeeks.cjs | 200 ++++++ .../test/utils/snapshotCertificateFixtures.ts | 139 ++++ scripts/mutation-partitions.mjs | 1 + scripts/mutation-partitions.test.mjs | 2 +- scripts/mutation-testing.test.mjs | 5 +- specs/wallet/sync-portability-program.md | 8 +- 33 files changed, 2791 insertions(+), 77 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.integration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.mysql.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexCrash.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysql.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysqlSchedules.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexSeeks.cjs create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotCertificateFixtures.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 785613d75..f0dac2bbb 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -312,6 +312,50 @@ qualification. Certificate fields, proof requests/proofs, source commit ordering nonblocking IndexedDB, streaming and staged restore remain required. Reader advertisement stays disabled and #569 remains open. +### Auxiliary certificate-field indexes (unpublished candidate) + +Migration `2026-10-01-005 add snapshot certificate field key indexes` adds +`snapshot_certificate_field_keys` and `snapshot_certificate_index_progress`. +Each field retains direct-user and parent-certificate membership independently, +including orphan fields and inconsistent ownership that the existing closure +checks must reject. Field/user/key changes, parent moves and physical deletion +update only the affected membership. Case-only and collation-equivalent renames +preserve the exact source field text. + +The auxiliary text key preserves the source field's ordering. MySQL copies its +`varchar(100)` character set and collation and requires transactional InnoDB +source and auxiliary tables. SQLite verifies the source's complete ascending +unique key and built-in BINARY, NOCASE or RTRIM collation. A differently collated +secondary index cannot redefine the cursor. Unsupported or inconsistent schema +metadata refuses migration instead of silently changing source ordering. +Standard tables, indexes, legacy OFFSET order and BRC-38/cursor encodings are +unchanged. Auxiliary tables remain excluded from portable archives. + +All removal observers precede producers. Bootstrap commits at most 256 source +fields and its text/composite position together; a separate started bit keeps an +empty field name valid. MySQL locks current source fields and parent ownership +through commit, including when an independent writer has an older consistent +read view. Parent maintenance uses the auxiliary certificate prefix and exact +source key rather than scanning all source fields. + +The migration has `transaction: false` and resumes partial owned DDL and committed +bootstrap positions. The migration journal and complete progress must both exist +inside the retained read view before ordinary pages, archive pages or closure +checks adopt the index. Missing journal entries retain the previous path; +journaled incomplete state refuses opening. Exclude other migrators before +recovering a verified stopped migrator's lock. Drain retained readers before +down; validate owned objects, remove producers before observers, and preserve all +standard rows and indexes. Keep verified backups and the candidate's other +binary-downgrade restrictions. + +The repository includes generated text/ownership schedules, independent MySQL +writer-lock checks, malformed metadata refusals and actual migration-process +termination at seven DDL/bootstrap boundaries on both databases. Native reader +fixtures measure first and late pages before and after optimizer statistics refresh. These +are synthetic source qualification fixtures, not deployed or physical mobile +acceptance. Proof/request indexes, commit ordering, nonblocking IndexedDB and +the remaining sync/portability program remain open. + ## Durable local SQL sync and ordinary backup With the version-one migration applied, supported local SQL providers use these diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 66a524df8..e96773af6 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. +- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index b98219254..57aa8f2b0 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -231,7 +231,9 @@ The [next complete run](https://github.com/bsv-blockchain/ts-stack/actions/runs/ also reached the 90-minute limit for retained reader/lifecycle, archive, remote reader and service guard execution. Retained profile and relation migrations now each execute as a whole-file part, alongside reader, storage and the lifecycle -fallback. Archive groups store/migration, source/closure and the capture fallback. +fallback. The subsequent certificate-field migration is registered as another +complete source and whole-file part with the same full retained test selection; +its independently defined fixture is included in the input digest. Archive groups store/migration, source/closure and the capture fallback. Remote reader groups lease, rows, page/open/cursor and the admission fallback. These partitions retain every original source specification and full test configuration. The single-file retained reader remains one complete part; diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 81467792f..8c3e5a32c 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -398,7 +398,10 @@ export function buildMutationTargets(repositoryRoot) { 'wallet-retained-snapshot': { packageDirectory: 'packages/wallet/wallet-toolbox', manifest: 'packages/wallet/wallet-toolbox/package.json', - additionalInputs: ['test/utils/snapshotRelationFixtures.ts'], + additionalInputs: [ + 'test/utils/snapshotRelationFixtures.ts', + 'test/utils/snapshotCertificateFixtures.ts' + ], propertyTest: 'packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts', mutate: [ @@ -406,6 +409,7 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/KnexWalletReadSnapshot.ts', 'src/storage/schema/snapshotProfileIndexMigration.ts', 'src/storage/schema/snapshotRelationIndexMigration.ts', + 'src/storage/schema/snapshotCertificateIndexMigration.ts', sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index ab9b17685..ed11a546d 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled." + "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view.", + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 88fbacb64..e75b5031b 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,12 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add resumable certificate-field profile indexes preserving source collation, + empty names, direct/parent ownership and exact rename bytes. Ordinary and + archive readers adopt complete migration state inside their retained view; + standard indexes, legacy OFFSET order and BRC-38/cursor bytes stay unchanged. + Proof/request indexing and the full program remain incomplete. + - Add auxiliary numeric relationship indexes for label/tag maps without changing standard indexes, composite cursors, legacy OFFSET order or BRC-38 bytes. Preserve both parent ownership bases through moves, rekeys, tombstones and diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index a7f27d739..1b59a820d 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -115,6 +115,11 @@ It preserves composite cursor and legacy OFFSET order, resumes bounded bootstrap after interruption, and bounds MySQL paging before and after statistics refresh with the auxiliary primary index and indexed source lookups. See its [migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-numeric-relation-indexes-unpublished-candidate). +The certificate-field migration adds collation-preserving composite keys and +independent direct/parent ownership. Empty text keys, interrupted bootstrap and +case-only renames retain their source meaning. It leaves standard indexes and +legacy cursor order intact; readers require the complete migration in their +pinned view. See the [certificate migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-certificate-field-indexes-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results @@ -142,7 +147,7 @@ Timing compares successive candidates, not a controlled comparison against upstr SQLite migration handling introduced in 2.13.2 runs transactional migration DDL and the migration journal update together. The unpublished profile-index and -numeric relation migrations are explicit resumable exceptions: auxiliary keys and progress commit +numeric relation and certificate-field migrations are explicit resumable exceptions: auxiliary keys and progress commit in bounded batches before its final migration journal entry. Foreign-key enforcement is disabled before the migration transaction for table rebuilds and restored after success or failure. Failed transactional migrations can be retried after reopening the database diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index bdcf00e81..8f3166f0f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,8 @@ +import { + addSnapshotCertificateIndexes, + removeSnapshotCertificateIndexes, + SNAPSHOT_CERTIFICATE_INDEX_MIGRATION +} from './snapshotCertificateIndexMigration' import { addSnapshotRelationIndexes, removeSnapshotRelationIndexes, @@ -42,6 +47,7 @@ import { LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS } from '../methods/managedChangePolicy' +export { SNAPSHOT_CERTIFICATE_INDEX_MIGRATION } from './snapshotCertificateIndexMigration' export { SNAPSHOT_RELATION_INDEX_MIGRATION } from './snapshotRelationIndexMigration' export { SNAPSHOT_PROFILE_INDEX_MIGRATION } from './snapshotProfileIndexMigration' export { SNAPSHOT_ARCHIVE_OWNER_MIGRATION } from './snapshotArchiveOwnerMigration' @@ -134,6 +140,12 @@ export class KnexMigrations implements MigrationSource { // DDL may commit independently on MySQL. Bootstrap pages retain their own // durable positions on both backends and resume before journal publication. + migrations[SNAPSHOT_CERTIFICATE_INDEX_MIGRATION] = { + config: { transaction: false }, + up: addSnapshotCertificateIndexes, + down: removeSnapshotCertificateIndexes + } + migrations[SNAPSHOT_RELATION_INDEX_MIGRATION] = { config: { transaction: false }, up: addSnapshotRelationIndexes, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts new file mode 100644 index 000000000..ca0d319aa --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts @@ -0,0 +1,612 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { runInSeries } from '../../utility/runInSeries' + +export const SNAPSHOT_CERTIFICATE_INDEX_MIGRATION = '2026-10-01-005 add snapshot certificate field key indexes' +const KEYS = 'snapshot_certificate_field_keys' +const PROGRESS = 'snapshot_certificate_index_progress' +const PAGE_ROWS = 256 +const keyColumns = ['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId'] +const indexes = [ + { + name: 'snapshot_certificate_parent', + columns: ['snapshotCertificateId', 'snapshotUserId', 'snapshotFieldName'] + }, + { + name: 'snapshot_certificate_lookup', + columns: ['snapshotFieldName', 'snapshotCertificateId', 'snapshotUserId'] + } +] +type Event = 'INSERT' | 'UPDATE' | 'DELETE' +interface Trigger { + name: string + table: string + timing: 'BEFORE' | 'AFTER' + event: Event + body: string + sql: string +} +interface TextDefinition { + charset: string | null + collation: string +} +function mysql(k: Knex): boolean { + return String(k.client.config.client).includes('mysql') +} +function normalized(sql: string): string { + return sql.replaceAll(/\s+/g, ' ').trim() +} +const textColumn = (name: string): boolean => name === 'snapshotFieldName' || name === 'afterFieldName' + +async function sourceText(k: Knex): Promise { + if (mysql(k)) { + const [tables]: Array> = await k.raw( + 'SELECT TABLE_NAME AS name, ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?, ?) ORDER BY TABLE_NAME', + ['certificate_fields', 'certificates'] + ) + if (tables.length !== 2 || tables.some(table => table.engine !== 'InnoDB')) + throw new WERR_INVALID_OPERATION('Snapshot certificate source requires transactional tables') + const [columns]: Array< + Array<{ + type: string + nullable: string + charset: string + collation: string + }> + > = await k.raw( + 'SELECT COLUMN_TYPE AS type, IS_NULLABLE AS nullable, CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + ['certificate_fields', 'fieldName'] + ) + const column = columns[0] + if ( + columns.length !== 1 || + column?.type !== 'varchar(100)' || + column.nullable !== 'NO' || + !/^[a-zA-Z0-9_]+$/.test(column.charset) || + !/^[a-zA-Z0-9_]+$/.test(column.collation) + ) + throw new WERR_INVALID_OPERATION('Unsupported snapshot certificate field definition') + return { charset: column.charset, collation: column.collation } + } + const unique: Array<{ name: string; unique: number; partial: number }> = await k.raw('PRAGMA index_list(??)', [ + 'certificate_fields' + ]) + for (const index of unique) { + if (index.unique !== 1 || index.partial !== 0) continue + const info: Array<{ + name: string + key: number + desc: number + coll: string + }> = await k.raw('PRAGMA index_xinfo(??)', [index.name]) + const parts = info.filter(part => part.key === 1) + if ( + parts.length !== 2 || + parts[0]?.name !== 'fieldName' || + parts[1]?.name !== 'certificateId' || + parts.some(part => part.desc !== 0) || + parts[1].coll !== 'BINARY' || + !['BINARY', 'NOCASE', 'RTRIM'].includes(parts[0].coll) + ) + continue + // A forced unique index must satisfy the declared column order itself. + // A separately collated index that needs a sort cannot define our cursor. + const plan: Array<{ detail: string }> = await k.raw( + 'EXPLAIN QUERY PLAN SELECT fieldName, certificateId FROM certificate_fields INDEXED BY ?? ORDER BY fieldName, certificateId LIMIT 1', + [index.name] + ) + if (plan.some(step => step.detail.includes('TEMP B-TREE'))) continue + return { charset: null, collation: parts[0].coll } + } + throw new WERR_INVALID_OPERATION('Unsupported snapshot certificate field order') +} + +function fieldType(text: TextDefinition): string { + return `varchar(100)${text.charset === null ? '' : ` CHARACTER SET ${text.charset}`} COLLATE ${text.collation}` +} +function insertMembership(isMysql: boolean, select: string, bit: number): string { + const merge = isMysql + ? ` ON DUPLICATE KEY UPDATE snapshotMembership = ${KEYS}.snapshotMembership | ${bit}` + : ` ON CONFLICT(${keyColumns.join(', ')}) DO UPDATE SET snapshotMembership = snapshotMembership | ${bit}` + return `INSERT INTO ${KEYS} (${keyColumns.join(', ')}, snapshotMembership) ${select}${merge};` +} +function trigger( + isMysql: boolean, + name: string, + table: string, + timing: Trigger['timing'], + event: Event, + statements: string, + changed?: string +): Trigger { + const body = + isMysql && changed !== undefined ? `BEGIN IF ${changed} THEN ${statements} END IF; END` : `BEGIN ${statements} END` + let qualifier = '' + if (isMysql) qualifier = ' FOR EACH ROW' + else if (changed !== undefined) qualifier = ` WHEN ${changed}` + return { + name, + table, + timing, + event, + body, + sql: `CREATE TRIGGER ${name} ${timing} ${event} ON ${table}${qualifier} ${body}` + } +} + +function triggers(isMysql: boolean): { + observers: Trigger[] + producers: Trigger[] +} { + const different = (key: string): string => + isMysql ? `NOT (OLD.${key} <=> NEW.${key})` : `OLD.${key} IS NOT NEW.${key}` + const fieldChanged = isMysql + ? 'NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))' + : 'CAST(OLD.fieldName AS BLOB) IS NOT CAST(NEW.fieldName AS BLOB)' + const changed = `${different('userId')} OR ${fieldChanged} OR ${different('certificateId')}` + const ownerChanged = `${different('userId')} OR ${different('certificateId')}` + const lock = isMysql ? ' FOR SHARE' : '' + const remove = `DELETE FROM ${KEYS} WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId;` + const add = + insertMembership(isMysql, 'VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1)', 1) + + ' ' + + insertMembership( + isMysql, + `SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId${lock}`, + 2 + ) + const where = 'snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId' + const subtract = `UPDATE ${KEYS} SET snapshotMembership = snapshotMembership & 1 WHERE ${where}; DELETE FROM ${KEYS} WHERE ${where} AND snapshotMembership = 0;` + // Direct memberships exist even when parent ownership is absent or differs. + // They supply a bounded parent prefix without changing legacy source indexes. + const join = isMysql ? 'JOIN' : 'CROSS JOIN' + const append = insertMembership( + isMysql, + `SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM ${KEYS} k ${join} certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId${lock}`, + 2 + ) + const prefix = 'snapshot_certificate' + return { + observers: [ + trigger(isMysql, `${prefix}_field_delete`, 'certificate_fields', 'AFTER', 'DELETE', remove), + trigger(isMysql, `${prefix}_field_before_update`, 'certificate_fields', 'BEFORE', 'UPDATE', remove, changed), + trigger(isMysql, `${prefix}_parent_delete`, 'certificates', 'AFTER', 'DELETE', subtract), + trigger(isMysql, `${prefix}_parent_before_update`, 'certificates', 'BEFORE', 'UPDATE', subtract, ownerChanged) + ], + producers: [ + trigger(isMysql, `${prefix}_field_insert`, 'certificate_fields', 'AFTER', 'INSERT', add), + trigger(isMysql, `${prefix}_field_after_update`, 'certificate_fields', 'AFTER', 'UPDATE', add, changed), + trigger(isMysql, `${prefix}_parent_insert`, 'certificates', 'AFTER', 'INSERT', append), + trigger(isMysql, `${prefix}_parent_after_update`, 'certificates', 'AFTER', 'UPDATE', append, ownerChanged) + ] + } +} +async function validateTrigger(k: Knex, expected: Trigger): Promise { + if (!mysql(k)) { + const row: { sql: string } | undefined = await k('sqlite_master') + .where({ type: 'trigger', name: expected.name }) + .first('sql') + if (row === undefined) return false + if (normalized(row.sql) !== normalized(expected.sql)) + throw new WERR_INVALID_OPERATION('Snapshot certificate trigger definition mismatch') + return true + } + const [rows]: Array> = await k.raw( + 'SELECT EVENT_MANIPULATION AS event, ACTION_TIMING AS timing, EVENT_OBJECT_TABLE AS tableName, ACTION_STATEMENT AS body FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME = ?', + [expected.name] + ) + if (!Array.isArray(rows)) throw new WERR_INVALID_OPERATION('Invalid snapshot certificate trigger metadata') + if (rows.length === 0) return false + const row = rows[0] + if ( + rows.length !== 1 || + row?.event !== expected.event || + row.timing !== expected.timing || + row.tableName !== expected.table || + normalized(row.body) !== normalized(expected.body) + ) { + throw new WERR_INVALID_OPERATION('Snapshot certificate trigger definition mismatch') + } + return true +} + +interface Column { + name: string + type: 'integer' | 'boolean' | 'varchar(100)' + unsigned?: boolean + primary: number +} +function tableColumns(keys: boolean): Column[] { + if (keys) + return [ + { name: 'snapshotUserId', type: 'integer', unsigned: true, primary: 1 }, + { name: 'snapshotFieldName', type: 'varchar(100)', primary: 2 }, + { + name: 'snapshotCertificateId', + type: 'integer', + unsigned: true, + primary: 3 + }, + { + name: 'snapshotMembership', + type: 'integer', + unsigned: true, + primary: 0 + } + ] + return [ + { name: 'snapshotTableId', type: 'integer', primary: 1 }, + { name: 'started', type: 'boolean', primary: 0 }, + { name: 'afterFieldName', type: 'varchar(100)', primary: 0 }, + { name: 'afterCertificateId', type: 'integer', unsigned: true, primary: 0 }, + { name: 'complete', type: 'boolean', primary: 0 } + ] +} +async function sqliteIndex(k: Knex, name: string, columns: string[], text: TextDefinition): Promise { + const rows: Array<{ name: string; desc: number; coll: string; key: number }> = await k.raw('PRAGMA index_xinfo(??)', [ + name + ]) + const parts = rows.filter(row => row.key === 1) + return ( + parts.length === columns.length && + parts.every( + (row, i) => + row.name === columns[i] && row.desc === 0 && row.coll === (textColumn(row.name) ? text.collation : 'BINARY') + ) + ) +} + +async function sqliteTable( + k: Knex, + table: string, + keys: boolean, + secondary: boolean, + text: TextDefinition +): Promise { + const expected = tableColumns(keys) + const columns: Array<{ + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + hidden: number + }> = await k.raw('PRAGMA table_xinfo(??)', [table]) + if ( + !Array.isArray(columns) || + columns.length !== expected.length || + columns.some((column, i) => { + const field = expected[i] + return ( + column.name !== field.name || + column.type.toLowerCase() !== field.type || + column.notnull !== (!keys && i === 0 ? 0 : 1) || + column.dflt_value !== null || + column.pk !== field.primary || + column.hidden !== 0 + ) + }) + ) + return false + const existing: Array<{ + name: string + unique: number + origin: string + partial: number + }> = await k.raw('PRAGMA index_list(??)', [table]) + if (existing.some(index => index.unique !== 0 && (index.origin !== 'pk' || index.partial !== 0))) return false + if (!keys) return true + const primary = existing.find(index => index.origin === 'pk') + if (primary === undefined || !(await sqliteIndex(k, primary.name, keyColumns, text))) return false + if (!secondary) return true + for (const expectedIndex of indexes) { + const found = existing.find(index => index.name === expectedIndex.name) + if (found?.unique !== 0 || found.partial !== 0 || !(await sqliteIndex(k, found.name, expectedIndex.columns, text))) + return false + } + return true +} + +async function mysqlTable( + k: Knex, + table: string, + keys: boolean, + secondary: boolean, + text: TextDefinition +): Promise { + const [tables]: Array> = await k.raw( + 'SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', + [table] + ) + if (tables.length !== 1 || tables[0]?.engine !== 'InnoDB') return false + const expected = tableColumns(keys) + const [columns]: Array< + Array<{ + name: string + type: string + nullable: string + defaultValue: unknown + extra: string + charset: string | null + collation: string | null + }> + > = await k.raw( + 'SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra, CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION', + [table] + ) + if ( + !Array.isArray(columns) || + columns.length !== expected.length || + columns.some((column, i) => { + const field = expected[i] + const integerType = field.unsigned === true ? 'int unsigned' : 'int' + const type = field.type === 'varchar(100)' ? field.type : field.type === 'boolean' ? 'tinyint' : integerType + return ( + column.name !== field.name || + (textColumn(column.name) ? column.type : column.type.replaceAll(/\(\d+\)/g, '')) !== type || + column.charset !== (textColumn(column.name) ? text.charset : null) || + column.collation !== (textColumn(column.name) ? text.collation : null) || + column.nullable !== 'NO' || + column.defaultValue !== null || + column.extra !== '' + ) + }) + ) + return false + const [parts]: Array< + Array<{ + name: string + columnName: string + nonUnique: number + direction: string + prefix: unknown + }> + > = await k.raw( + 'SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX', + [table] + ) + if (!Array.isArray(parts) || parts.some(index => index.name !== 'PRIMARY' && Number(index.nonUnique) !== 1)) + return false + const required = [ + { name: 'PRIMARY', columns: keys ? keyColumns : ['snapshotTableId'] }, + ...(keys && secondary ? indexes : []) + ] + return required.every(index => { + const found = parts.filter(part => part.name === index.name) + return ( + found.length === index.columns.length && + found.every((part, i) => part.columnName === index.columns[i] && part.direction === 'A' && part.prefix === null) + ) + }) +} + +async function validateTable(k: Knex, table: string, text: TextDefinition, secondary = true): Promise { + const valid = mysql(k) + ? await mysqlTable(k, table, table === KEYS, secondary, text) + : await sqliteTable(k, table, table === KEYS, secondary, text) + if (!valid) throw new WERR_INVALID_OPERATION('Snapshot certificate table definition mismatch') +} + +async function ensureTables(k: Knex, text: TextDefinition): Promise { + if (!(await k.schema.hasTable(KEYS))) { + await k.schema.createTable(KEYS, t => { + if (mysql(k)) void t.engine('InnoDB') + t.integer('snapshotUserId').unsigned().notNullable() + t.specificType('snapshotFieldName', fieldType(text)).notNullable() + t.integer('snapshotCertificateId').unsigned().notNullable() + t.integer('snapshotMembership').unsigned().notNullable() + t.primary(keyColumns) + }) + } + // MySQL DDL commits independently. Resume an interrupted creation between + // the table and either auxiliary index without trusting a conflicting index. + await validateTable(k, KEYS, text, false) + await runInSeries(indexes, async index => { + const exists = mysql(k) + ? ( + await k.raw( + 'SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1', + [KEYS, index.name] + ) + )[0].length !== 0 + : (await k('sqlite_master').where({ type: 'index', name: index.name }).first('name')) !== undefined + if (!exists) + await k.schema.alterTable(KEYS, t => { + t.index(index.columns, index.name) + }) + }) + await validateTable(k, KEYS, text) + if (!(await k.schema.hasTable(PROGRESS))) { + await k.schema.createTable(PROGRESS, t => { + if (mysql(k)) void t.engine('InnoDB') + t.integer('snapshotTableId').primary() + t.boolean('started').notNullable() + t.specificType('afterFieldName', fieldType(text)).notNullable() + t.integer('afterCertificateId').unsigned().notNullable() + t.boolean('complete').notNullable() + }) + } + await validateTable(k, PROGRESS, text) +} + +interface Position { + started: boolean | number + afterFieldName: string + afterCertificateId: number + complete: boolean | number +} +function validPosition(state: Position | undefined): state is Position { + return ( + state !== undefined && + [false, true, 0, 1].includes(state.started) && + typeof state.afterFieldName === 'string' && + Array.from(state.afterFieldName).length <= 100 && + Number.isSafeInteger(state.afterCertificateId) && + state.afterCertificateId >= 0 && + [false, true, 0, 1].includes(state.complete) && + (state.started === true || state.started === 1 + ? state.afterCertificateId > 0 + : state.afterCertificateId === 0 && state.afterFieldName === '') + ) +} +function positive(value: number | undefined): number { + if (value === undefined || !Number.isSafeInteger(value) || value < 1) + throw new WERR_INVALID_OPERATION('Invalid snapshot certificate source key') + return value +} +async function bootstrapPage(k: Knex): Promise { + return await k.transaction(async trx => { + if (!mysql(k)) + await trx(PROGRESS) + .where('snapshotTableId', 0) + .update({ started: trx.ref('started') }) + const progress = trx(PROGRESS).where('snapshotTableId', 0) + if (mysql(k)) void progress.forUpdate() + const state: Position | undefined = await progress.first() + if (!validPosition(state)) throw new WERR_INVALID_OPERATION('Invalid snapshot certificate bootstrap position') + if (state.complete === true || state.complete === 1) return true + const source = trx('certificate_fields') + .select('userId', 'fieldName', 'certificateId') + .orderBy(['fieldName', 'certificateId']) + .limit(PAGE_ROWS) + if (mysql(k)) void source.forUpdate() + if (state.started === true || state.started === 1) { + if (mysql(k)) + void source.whereRaw('(fieldName > ? OR (fieldName = ? AND certificateId > ?))', [ + state.afterFieldName, + state.afterFieldName, + state.afterCertificateId + ]) + else void source.whereRaw('(fieldName, certificateId) > (?, ?)', [state.afterFieldName, state.afterCertificateId]) + } + const rows: Array<{ + userId: number + fieldName: string + certificateId: number + }> = await source + for (const row of rows) { + positive(row.userId) + positive(row.certificateId) + if (typeof row.fieldName !== 'string' || Array.from(row.fieldName).length > 100) + throw new WERR_INVALID_OPERATION('Invalid snapshot certificate source key') + } + if (rows.length !== 0) { + const parentQuery = trx('certificates') + .select('certificateId', 'userId') + .whereIn( + 'certificateId', + [...new Set(rows.map(row => row.certificateId))].sort((a, b) => a - b) + ) + .orderBy('certificateId') + if (mysql(k)) void parentQuery.forShare() + const parents: Array<{ certificateId: number; userId: number }> = await parentQuery + const owners = new Map(parents.map(row => [positive(row.certificateId), positive(row.userId)])) + await runInSeries([1, 2], async bit => { + const memberships = rows.flatMap(row => { + const owner = bit === 1 ? row.userId : owners.get(row.certificateId) + return owner === undefined + ? [] + : [ + { + snapshotUserId: owner, + snapshotFieldName: row.fieldName, + snapshotCertificateId: row.certificateId, + snapshotMembership: bit + } + ] + }) + if (memberships.length !== 0) + await trx(KEYS) + .insert(memberships) + .onConflict(keyColumns) + .merge({ + snapshotMembership: trx.raw('?? | ?', ['snapshotMembership', bit]) + }) + }) + } + const last = rows.at(-1), + complete = rows.length < PAGE_ROWS + await trx(PROGRESS) + .where('snapshotTableId', 0) + .update({ + started: last === undefined ? state.started : true, + afterFieldName: last?.fieldName ?? state.afterFieldName, + afterCertificateId: last?.certificateId ?? state.afterCertificateId, + complete + }) + return complete + }) +} +export async function addSnapshotCertificateIndexes(k: Knex): Promise { + if (mysql(k) && k.isTransaction) + throw new WERR_INVALID_OPERATION( + 'Snapshot certificate migration requires independent DDL and bootstrap transactions' + ) + const text = await sourceText(k) + await ensureTables(k, text) + const { observers, producers } = triggers(mysql(k)) + await runInSeries([...observers, ...producers], async expected => { + if (!(await validateTrigger(k, expected))) await k.raw(expected.sql) + }) + await k(PROGRESS) + .insert({ + snapshotTableId: 0, + started: false, + afterFieldName: '', + afterCertificateId: 0, + complete: false + }) + .onConflict('snapshotTableId') + .ignore() + let complete = false + function* unfinishedPages() { + while (!complete) yield undefined + } + await runInSeries(unfinishedPages(), async () => { + complete = await bootstrapPage(k) + }) +} +/** Readers must be drained before removal; source rows and indexes stay intact. */ +export async function removeSnapshotCertificateIndexes(k: Knex): Promise { + if (mysql(k) && k.isTransaction) + throw new WERR_INVALID_OPERATION( + 'Snapshot certificate migration requires independent DDL and bootstrap transactions' + ) + const text = await sourceText(k) + if (await k.schema.hasTable(KEYS)) await validateTable(k, KEYS, text) + if (await k.schema.hasTable(PROGRESS)) await validateTable(k, PROGRESS, text) + const { observers, producers } = triggers(mysql(k)), + ordered = [...producers, ...observers] + await runInSeries(ordered, async expected => { + await validateTrigger(k, expected) + }) + await runInSeries(ordered, async expected => { + await k.raw('DROP TRIGGER IF EXISTS ??', [expected.name]) + }) + await k.schema.dropTableIfExists(PROGRESS) + await k.schema.dropTableIfExists(KEYS) +} +/** Resolve adoption inside the same retained view as the source header/pages. */ +export async function readSnapshotCertificateIndexState(k: Knex, config?: Knex.MigratorConfig): Promise { + const tableName = config?.tableName ?? 'knex_migrations', + schema = k.schema + if (config?.schemaName !== undefined) void schema.withSchema(config.schemaName) + if (!(await schema.hasTable(tableName))) return false + const journal = k(tableName).where('name', SNAPSHOT_CERTIFICATE_INDEX_MIGRATION) + if (config?.schemaName !== undefined) void journal.withSchema(config.schemaName) + if ((await journal.first('name')) === undefined) return false + if (!(await k.schema.hasTable(KEYS)) || !(await k.schema.hasTable(PROGRESS))) + throw new WERR_INVALID_OPERATION('Snapshot certificate index migration is incomplete') + const text = await sourceText(k) + await validateTable(k, KEYS, text) + await validateTable(k, PROGRESS, text) + const states: Array = await k(PROGRESS).select('*').limit(2) + if ( + states.length !== 1 || + states[0]?.snapshotTableId !== 0 || + !validPosition(states[0]) || + (states[0].complete !== true && states[0].complete !== 1) + ) + throw new WERR_INVALID_OPERATION('Snapshot certificate index migration is incomplete') + return true +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts index 0a36b0d83..9d5757ccb 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -67,7 +67,7 @@ test('a ready request waits for owned reader destruction while foreground storag const source = (await storage.openSnapshotArchiveSource(identity))! expect(source.user.identityKey).toBe(identity) expect(source.sourceStorage.storageIdentityKey).toBe('original-source') - expect(source.sourceSchema).toBe('2026-10-01-004 add snapshot relation key indexes') + expect(source.sourceSchema).toBe('2026-10-01-005 add snapshot certificate field key indexes') const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex expect(reader.knex).not.toBe(storage.knex) expect(reader.knex.client.config.pool).toMatchObject({ min: 0, max: 1 }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts index b570bef03..0619dbc31 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts @@ -208,3 +208,22 @@ test.each([-1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( } } ) + +test('MySQL certificate pages preserve the collation-aware auxiliary key and indexed source lookup', () => { + const k = knex({ client: 'mysql2' }) + const q = walletSnapshotSourceQuery(k, 'certificateFields', 41, true, true, true) + .select('certificate_fields.*') + .toSQL() + expect(q.sql).toContain('/*+ JOIN_FIXED_ORDER() JOIN_INDEX(certificate_fields) */') + expect(q.sql).toContain( + 'from `snapshot_certificate_field_keys` FORCE INDEX (`PRIMARY`) cross join `certificate_fields`' + ) + expect(q.sql).toContain( + '`snapshotFieldName` = `certificate_fields`.`fieldName` and `snapshotCertificateId` = `certificate_fields`.`certificateId`' + ) + expect(q.sql).toContain('`snapshotMembership` between ? and ?') + expect(q.bindings).toEqual([41, 1, 3]) + const legacy = walletSnapshotSourceQuery(k, 'certificateFields', 41, true, true).toSQL() + expect(legacy.sql).not.toContain('snapshot_certificate_field_keys') + expect(legacy.sql).toContain('or exists') +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts index e17619abf..e528627b5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts @@ -1,3 +1,7 @@ +import { + removeSnapshotCertificateIndexes, + SNAPSHOT_CERTIFICATE_INDEX_MIGRATION +} from '../schema/snapshotCertificateIndexMigration' import { removeSnapshotProfileIndexes, SNAPSHOT_PROFILE_INDEX_MIGRATION } from '../schema/snapshotProfileIndexMigration' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -563,13 +567,17 @@ test('concurrent reads refuse instead of queueing and expiry invalidates cursors }) test.each([ - [false, false], - [false, true], - [true, false], - [true, true] + [false, false, false], + [false, false, true], + [false, true, false], + [false, true, true], + [true, false, false], + [true, false, true], + [true, true, false], + [true, true, true] ])( - 'SQL keys support forward seeks without OFFSET or counts (profileIndexes=%s, relationIndexes=%s)', - async (profileIndexes, relationIndexes) => { + 'SQL keys support forward seeks without OFFSET or counts (profile=%s, relation=%s, certificate=%s)', + async (profileIndexes, relationIndexes, certificateIndexes) => { const { source, userId, otherId } = await fixture() if (!profileIndexes) { await removeSnapshotProfileIndexes(source.knex) @@ -579,6 +587,10 @@ test.each([ await removeSnapshotRelationIndexes(source.knex) await source.knex('knex_migrations').where('name', SNAPSHOT_RELATION_INDEX_MIGRATION).delete() } + if (!certificateIndexes) { + await removeSnapshotCertificateIndexes(source.knex) + await source.knex('knex_migrations').where('name', SNAPSHOT_CERTIFICATE_INDEX_MIGRATION).delete() + } await seedClosure(source, userId, otherId) const view = await source.openWalletReadSnapshot(identity) const requests: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] @@ -598,6 +610,9 @@ test.each([ expect(subsequent.filter(query => query.sql.includes('snapshot_relation_keys'))).toHaveLength( relationIndexes ? 2 : 0 ) + expect(subsequent.filter(query => query.sql.includes('snapshot_certificate_field_keys'))).toHaveLength( + certificateIndexes ? 2 : 0 + ) for (const query of subsequent) { expect(query.sql).not.toMatch(/offset|count\(/i) const plan = await source.knex.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) @@ -613,6 +628,12 @@ test.each([ /SEARCH snapshot_relation_keys USING COVERING INDEX .*snapshotTableId=\? AND snapshotUserId=\? AND \(snapshotLeftId,snapshotRightId\)>\(\?,\?\)/ ) expect(details).not.toMatch(/SCAN |TEMP B-TREE/) + } else if (query.sql.includes('snapshot_certificate_field_keys')) { + expect(details).toMatch( + /SEARCH snapshot_certificate_field_keys USING COVERING INDEX .*snapshotUserId=\? AND \(snapshotFieldName,snapshotCertificateId\)>\(\?,\?\)/ + ) + expect(details).toMatch(/SEARCH certificate_fields USING INDEX .*fieldName=\? AND certificateId=\?/) + expect(details).not.toMatch(/SCAN |TEMP B-TREE/) } else { expect(details).toMatch(/SEARCH (tx_labels|tx_labels_map|certificate_fields) USING .*\(.*>\(?\?/) } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index 03cfce292..082d2dac3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -1,3 +1,4 @@ +import { readSnapshotCertificateIndexState } from '../schema/snapshotCertificateIndexMigration' import { readSnapshotRelationIndexState } from '../schema/snapshotRelationIndexMigration' import { readSnapshotProfileIndexState } from '../schema/snapshotProfileIndexMigration' import { SnapshotResourceLimitError } from './SnapshotResourceLimitError' @@ -135,7 +136,8 @@ export function walletSnapshotSourceQuery( table: WalletSnapshotTable, userId: number, profileIndexes = false, - relationIndexes = false + relationIndexes = false, + certificateIndexes = false ): Knex.QueryBuilder { const { name } = definitions[table] const tableId = auxiliaryTableIds[table] @@ -164,6 +166,26 @@ export function walletSnapshotSourceQuery( void query.whereBetween('snapshotMembership', [1, 3]).hintComment(['JOIN_FIXED_ORDER()', `JOIN_INDEX(${name})`]) return query } + if (certificateIndexes && table === 'certificateFields') { + const mysql = String(k.client.config.client).includes('mysql') + const keys = mysql + ? k.raw('?? FORCE INDEX (??)', ['snapshot_certificate_field_keys', 'PRIMARY']) + : 'snapshot_certificate_field_keys' + const query = k(keys) + .crossJoin(name, function () { + void this.on('snapshotFieldName', '=', `${name}.fieldName`).andOn( + 'snapshotCertificateId', + '=', + `${name}.certificateId` + ) + }) + .where('snapshotUserId', userId) + if (mysql) + void query + .whereBetween('snapshotMembership', [1, 3]) + .hintComment(['JOIN_FIXED_ORDER()', 'JOIN_INDEX(certificate_fields)']) + return query + } const query = k(name) if (table === 'provenTxReqs') { return query.whereExists(owned(k, 'transactions', 'txid', `${name}.txid`, userId)) @@ -272,6 +294,7 @@ interface SnapshotContext { snapshotId: string profileIndexes: boolean relationIndexes: boolean + certificateIndexes: boolean columns: Map } @@ -306,7 +329,7 @@ async function readPage( after: Array | undefined, limits: { maxRows: number; maxBytes: number } ): Promise> { - const { storage, userId, columns, snapshotId, profileIndexes, relationIndexes } = context + const { storage, userId, columns, snapshotId, profileIndexes, relationIndexes, certificateIndexes } = context const k = storage.toDb(trx) const schema = definitions[table] let fields = columns.get(table) @@ -319,8 +342,9 @@ async function readPage( if (profileIndexes && auxiliaryTableIds[table] !== undefined) orderKeys = ['snapshotRowId'] if (relationIndexes && auxiliaryRelationTableIds[table] !== undefined) orderKeys = ['snapshotLeftId', 'snapshotRightId'] + if (certificateIndexes && table === 'certificateFields') orderKeys = ['snapshotFieldName', 'snapshotCertificateId'] const base = (): Knex.QueryBuilder => { - const q = walletSnapshotSourceQuery(k, table, userId, profileIndexes, relationIndexes) + const q = walletSnapshotSourceQuery(k, table, userId, profileIndexes, relationIndexes, certificateIndexes) if (after !== undefined) seek(q, orderKeys, after, false, storage.dbtype === 'MySQL') for (const key of orderKeys) void q.orderBy(key) return q @@ -354,9 +378,18 @@ export function createKnexWalletSnapshotPageReader( snapshotId: string, view: RetainedReadSnapshot, profileIndexes = false, - relationIndexes = false + relationIndexes = false, + certificateIndexes = false ): WalletReadSnapshot['readPage'] { - const context: SnapshotContext = { storage, userId, snapshotId, profileIndexes, relationIndexes, columns: new Map() } + const context: SnapshotContext = { + storage, + userId, + snapshotId, + profileIndexes, + relationIndexes, + certificateIndexes, + columns: new Map() + } return async ( table: T, cursor?: WalletSnapshotCursor, @@ -381,14 +414,18 @@ export async function openKnexWalletReadSnapshot( } const view = await storage.openReadSnapshot(options) try { - const { header, profileIndexes, relationIndexes } = await view.read(async trx => { + const { header, profileIndexes, relationIndexes, certificateIndexes } = await view.read(async trx => { const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') return { header: { sourceStorage, user }, profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - relationIndexes: await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations) + relationIndexes: await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations), + certificateIndexes: await readSnapshotCertificateIndexState( + storage.toDb(trx), + storage.knex.client.config.migrations + ) } }) const userId = header.user.userId @@ -403,7 +440,15 @@ export async function openKnexWalletReadSnapshot( }, closed: view.closed, close: view.close, - readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view, profileIndexes, relationIndexes) + readPage: createKnexWalletSnapshotPageReader( + storage, + userId, + snapshotId, + view, + profileIndexes, + relationIndexes, + certificateIndexes + ) } } catch (error) { // Keep the opening error authoritative while still awaiting physical cleanup. diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.integration.test.ts new file mode 100644 index 000000000..5fbf20251 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.integration.test.ts @@ -0,0 +1,239 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex, type Knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { runInSeries } from '../../utility/runInSeries' +import { seedArchiveClosure } from '../../../test/utils/snapshotArchiveFixtures' +import { snapshotArchiveTables } from './archive/SnapshotArchive' +import { openKnexSnapshotArchiveSource, type SnapshotArchiveSource } from './archive/KnexSnapshotArchiveSource' +import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' +import { + addSnapshotCertificateIndexes, + removeSnapshotCertificateIndexes, + SNAPSHOT_CERTIFICATE_INDEX_MIGRATION +} from '../schema/snapshotCertificateIndexMigration' + +const identity = '02' + '11'.repeat(32) +const stores: StorageKnex[] = [] +const writers: Knex[] = [] +const directories: string[] = [] +const dates = { created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z' } +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-certificate-index-')) + directories.push(directory) + const options = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + } + const k = knex(options) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + stores.push(source) + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('certificate index fixture', 'synthetic-certificate-index') + await source.makeAvailable() + // Start with the immediately preceding schema on both old and new source. + await removeSnapshotCertificateIndexes(k) + await k('knex_migrations').where('name', SNAPSHOT_CERTIFICATE_INDEX_MIGRATION).delete() + const { user } = await source.findOrInsertUser(identity) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, other.userId) + const writer = knex(options) + writers.push(writer) + return { source, writer, k, userId: user.userId, otherId: other.userId } +} +async function journal(k: Knex): Promise { + await k('knex_migrations').insert({ + name: SNAPSHOT_CERTIFICATE_INDEX_MIGRATION, + batch: 99, + migration_time: new Date() + }) +} +async function pages(view: WalletReadSnapshot | SnapshotArchiveSource) { + expect(Object.hasOwn(view, 'certificateIndexes')).toBe(false) + if ('validateClosure' in view) await view.validateClosure() + const result: Record = {} + await runInSeries(snapshotArchiveTables, async table => { + let cursor: WalletSnapshotCursor | undefined + const selected: unknown[] = [] + let complete = false + for (let pageNumber = 0; pageNumber < 25 && !complete; pageNumber++) { + const page = await view.readPage(table, cursor, { maxRows: 1, maxBytes: 131072 }) + selected.push({ rows: page.rows, after: page.cursor?.after, payloadBytes: page.payloadBytes, done: page.done }) + complete = page.done + cursor = page.cursor + } + expect(complete).toBe(true) + result[table] = selected + }) + return result +} +afterEach(async () => { + await runInSeries(writers.splice(0), k => k.destroy()) + await runInSeries(stores.splice(0), source => source.destroy()) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) + +test('indexed ordinary/archive pages preserve all13tables and original source indexes/legacy offsets', async () => { + const { source, k, userId } = await fixture() + const legacy = async () => { + const all: Record>> = {} + await runInSeries( + [ + ['findTransactions', 'transactionId'], + ['findOutputs', 'outputId'], + ['findCertificates', 'certificateId'], + ['findTxLabels', 'txLabelId'], + ['findOutputBaskets', 'basketId'], + ['findOutputTags', 'outputTagId'], + ['findCommissions', 'commissionId'], + ['findSyncStates', 'syncStateId'] + ] as const, + async ([method, key]) => { + all[method] = [] + await runInSeries([0, 1], async offset => { + const rows = await source[method]({ partial: { userId }, paged: { limit: 1, offset } }) + all[method].push(rows.map(row => (row as unknown as Record)[key])) + }) + } + ) + all.certificateFields = [] + await runInSeries([0, 1], async offset => { + const rows = await source.findCertificateFields({ partial: { userId }, paged: { limit: 1, offset } }) + all.certificateFields.push(rows.flatMap(row => [row.fieldName, row.certificateId])) + }) + return all + } + const standard = async () => + await k('sqlite_master') + .whereIn('type', ['table', 'index']) + .whereNotIn('tbl_name', ['snapshot_certificate_field_keys', 'snapshot_certificate_index_progress']) + .select('type', 'name', 'tbl_name', 'sql') + .orderBy('name') + const originalIndexes = await standard() + const originalOffsets = await legacy() + const old = await source.openWalletReadSnapshot(identity) + let baseline + try { + baseline = await pages(old) + } finally { + await old.close() + } + await addSnapshotCertificateIndexes(k) + expect(await standard()).toEqual(originalIndexes) + expect(await legacy()).toEqual(originalOffsets) + const queries: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] + const listen = (query: { sql: string; bindings: Knex.RawBinding[] }): void => { + if ( + query.sql.startsWith('select') && + query.sql.includes('cross join') && + query.sql.includes('snapshot_certificate_field_keys') + ) + queries.push(query) + } + k.on('query', listen) + try { + const partial = await source.openWalletReadSnapshot(identity) + try { + expect(await pages(partial)).toEqual(baseline) + expect(queries).toEqual([]) + } finally { + await partial.close() + } + await journal(k) + await runInSeries( + [() => source.openWalletReadSnapshot(identity), () => openKnexSnapshotArchiveSource(source, identity)], + async open => { + const view = await open() + try { + expect(await pages(view)).toEqual(baseline) + } finally { + await view.close() + } + } + ) + expect(queries.length).toBeGreaterThan(0) + } finally { + k.off('query', listen) + } + // Explain only page queries; closure may legitimately visit its certificateal parents. + await runInSeries( + queries.filter(query => query.sql.includes('__snapshotBytes') || query.sql.includes('.*')), + async query => { + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) + expect(plan.some(row => row.detail.includes('SEARCH snapshot_certificate_field_keys USING COVERING INDEX'))).toBe( + true + ) + expect(plan.some(row => /SCAN |TEMP B-TREE/.test(row.detail))).toBe(false) + } + ) +}) + +test.each(['ordinary', 'archive'] as const)( + '%s mode and all13table pages stay bound across independent journal/profile/progress writes', + async kind => { + const { source, k, writer, userId, otherId } = await fixture() + await addSnapshotCertificateIndexes(k) + const open = async () => + kind === 'ordinary' + ? await source.openWalletReadSnapshot(identity) + : await openKnexSnapshotArchiveSource(source, identity) + const queries: string[] = [] + const listen = (query: { sql: string }): void => { + if ( + query.sql.startsWith('select') && + query.sql.includes('cross join') && + query.sql.includes('snapshot_certificate_field_keys') + ) + queries.push(query.sql) + } + k.on('query', listen) + const old = await open() + let baseline + try { + baseline = await pages(old) + queries.length = 0 + await journal(writer) + expect(await pages(old)).toEqual(baseline) + expect(queries).toEqual([]) + } finally { + await old.close() + } + const indexed = await open() + try { + await writer.transaction(async trx => { + await trx('snapshot_certificate_index_progress').where('snapshotTableId', 0).update({ complete: 0 }) + await trx('certificates').where('certificateId', 3).update({ userId: otherId }) + await trx('certificate_fields').where('certificateId', 3).update({ userId: otherId }) + await trx('certificates').insert({ + ...dates, + certificateId: 4, + userId, + serialNumber: 'new-after-view', + type: 'type', + certifier: identity, + subject: identity, + revocationOutpoint: 'a'.repeat(64) + '.0', + signature: 'synthetic', + isDeleted: false + }) + }) + expect(await pages(indexed)).toEqual(baseline) + expect(queries.length).toBeGreaterThan(0) + } finally { + await indexed.close() + k.off('query', listen) + } + await expect(open()).rejects.toThrow('migration is incomplete') + await writer('snapshot_certificate_index_progress').where('snapshotTableId', 0).update({ complete: 1 }) + const fresh = await open() + try { + expect((await fresh.readPage('certificates')).rows.map(row => row.certificateId)).toEqual([1, 4]) + } finally { + await fresh.close() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.mysql.test.ts new file mode 100644 index 000000000..b28248c06 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.mysql.test.ts @@ -0,0 +1,360 @@ +import { knex } from 'knex' +import { + addSnapshotCertificateIndexes as install, + removeSnapshotCertificateIndexes as remove, + readSnapshotCertificateIndexState as enabled, + SNAPSHOT_CERTIFICATE_INDEX_MIGRATION as migration +} from '../schema/snapshotCertificateIndexMigration' + +type Kind = 'sourceTables' | 'sourceColumn' | 'tables' | 'columns' | 'indexes' | 'triggers' +type Metadata = Array> + +// Actual installed MySQL compiler/transaction protocol. Separate native tests +// prove database locking, collation, crash recovery and row-read behavior. +function fixture(change: (kind: Kind, rows: Metadata) => unknown = (_kind, rows) => rows) { + const k = knex({ client: 'mysql2' }) + const queries: Array<{ sql: string; values: unknown[] }> = [] + const tables = new Set(['knex_migrations']) + const indexes = new Map() + const triggers = new Map>() + let progress: + | { + snapshotTableId: number + started: boolean + afterFieldName: string + afterCertificateId: number + complete: boolean + } + | undefined + const keys: Array<{ + snapshotUserId: number + snapshotFieldName: string + snapshotCertificateId: number + snapshotMembership: number + }> = [] + let journaled = false + const answer = (sql: string, values: unknown[]): unknown => { + queries.push({ sql, values }) + if (sql.startsWith('SELECT TABLE_NAME AS name')) + return change('sourceTables', [ + { name: 'certificate_fields', engine: 'InnoDB' }, + { name: 'certificates', engine: 'InnoDB' } + ]) + if (sql.startsWith('SELECT COLUMN_TYPE AS type')) + return change('sourceColumn', [ + { type: 'varchar(100)', nullable: 'NO', charset: 'utf8mb4', collation: 'utf8mb4_0900_ai_ci' } + ]) + if (sql.startsWith('SELECT ENGINE AS engine')) return change('tables', [{ engine: 'InnoDB' }]) + if (sql.startsWith('select * from information_schema.tables')) + return tables.has(String(values[0])) ? [{ TABLE_NAME: values[0] }] : [] + if (sql.startsWith('create table')) { + tables.add(sql.match(/^create table `([^`]+)`/)![1]) + return [] + } + if (sql.startsWith('drop table')) { + tables.delete(sql.match(/`([^`]+)`/)![1]) + return [] + } + if (sql.startsWith('SELECT COLUMN_NAME')) { + const isKeys = values[0] === 'snapshot_certificate_field_keys' + const names = isKeys + ? ['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId', 'snapshotMembership'] + : ['snapshotTableId', 'started', 'afterFieldName', 'afterCertificateId', 'complete'] + const types = isKeys + ? ['int unsigned', 'varchar(100)', 'int unsigned', 'int unsigned'] + : ['int', 'tinyint', 'varchar(100)', 'int unsigned', 'tinyint'] + return change( + 'columns', + names.map((name, i) => ({ + name, + type: types[i], + nullable: 'NO', + defaultValue: null, + extra: '', + charset: types[i] === 'varchar(100)' ? 'utf8mb4' : null, + collation: types[i] === 'varchar(100)' ? 'utf8mb4_0900_ai_ci' : null + })) + ) + } + if (sql.startsWith('alter table `snapshot_certificate_field_keys` add index')) { + indexes.set( + sql.match(/add index `([^`]+)`/)![1], + [...sql.matchAll(/`([^`]+)`/g)].slice(2).map(match => match[1]) + ) + return [] + } + if (sql.startsWith('SELECT INDEX_NAME FROM')) + return indexes.has(String(values[1])) ? [{ INDEX_NAME: values[1] }] : [] + if (sql.startsWith('SELECT INDEX_NAME AS')) { + const isKeys = values[0] === 'snapshot_certificate_field_keys' + const primary = isKeys ? ['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId'] : ['snapshotTableId'] + return change('indexes', [ + ...primary.map(columnName => ({ name: 'PRIMARY', columnName, nonUnique: 0, direction: 'A', prefix: null })), + ...(isKeys + ? [...indexes].flatMap(([name, columns]) => + columns.map(columnName => ({ name, columnName, nonUnique: 1, direction: 'A', prefix: null })) + ) + : []) + ]) + } + if (sql.startsWith('SELECT EVENT_MANIPULATION')) + return change('triggers', triggers.has(String(values[0])) ? [triggers.get(String(values[0]))!] : []) + if (sql.startsWith('CREATE TRIGGER')) { + const [, name, timing, event, tableName, body] = sql.match( + /^CREATE TRIGGER (\w+) (BEFORE|AFTER) (\w+) ON (\w+) FOR EACH ROW (.*)$/ + )! + triggers.set(name, { event, timing, tableName, body }) + return [] + } + if (sql.startsWith('DROP TRIGGER')) { + triggers.delete(sql.split(' ').at(-1)!.replaceAll('`', '')) + return [] + } + if (sql.startsWith('insert ignore into `snapshot_certificate_index_progress`')) { + const [afterCertificateId, afterFieldName, complete, snapshotTableId, started] = values as [ + number, + string, + boolean, + number, + boolean + ] + progress ??= { snapshotTableId, started, afterFieldName, afterCertificateId, complete } + return { affectedRows: 1, insertId: 0 } + } + if (sql.startsWith('select * from `snapshot_certificate_index_progress`')) { + if (sql.includes('where')) expect(sql).toMatch(/for update$/) + else expect(values).toEqual([2]) + return progress === undefined ? [] : [{ ...progress }] + } + if (sql.startsWith('update `snapshot_certificate_index_progress`')) { + const [started, afterFieldName, afterCertificateId, complete, tableId] = values as [ + boolean, + string, + number, + boolean, + number + ] + expect(tableId).toBe(0) + Object.assign(progress!, { started, afterFieldName, afterCertificateId, complete }) + return { affectedRows: 1 } + } + if (sql.startsWith('insert into `snapshot_certificate_field_keys`')) { + const fields = sql + .slice(sql.indexOf('(') + 1, sql.indexOf(')')) + .split(', ') + .map(value => value.replaceAll('`', '')) + for (let i = 0; i < values.length - 1; i += fields.length) { + const row = Object.fromEntries( + fields.map((field, offset) => [field, values[i + offset]]) + ) as (typeof keys)[number] + const found = keys.find( + key => + key.snapshotUserId === row.snapshotUserId && + key.snapshotFieldName === row.snapshotFieldName && + key.snapshotCertificateId === row.snapshotCertificateId + ) + if (found === undefined) keys.push(row) + else found.snapshotMembership |= Number(values.at(-1)) + } + return { affectedRows: 1 } + } + if (sql.startsWith('select `name` from `knex_migrations`')) return journaled ? [{ name: migration }] : [] + if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return [] + if (sql.startsWith('select `userId`, `fieldName`, `certificateId` from `certificate_fields`')) { + expect(sql).toMatch(/for update$/) + expect(values.at(-1)).toBe(256) + const rows = Array.from({ length: 257 }, (_, i) => ({ + userId: 1, + fieldName: String(i).padStart(6, '0'), + certificateId: i + 1 + })) + if (values.length === 1) return rows.slice(0, 256) + expect(sql).toContain('(fieldName > ? OR (fieldName = ? AND certificateId > ?))') + return rows + .filter( + row => + row.fieldName > String(values[0]) || (row.fieldName === values[1] && row.certificateId > Number(values[2])) + ) + .slice(0, 256) + } + if (sql.startsWith('select `certificateId`, `userId` from `certificates`')) { + expect(sql).toMatch(/lock in share mode$/) + return values.map(certificateId => ({ certificateId, userId: (Number(certificateId) % 2) + 1 })) + } + throw new Error('Unexpected synthetic driver query: ' + sql) + } + const connection = { + query( + query: { sql: string }, + values: unknown[], + callback: (error: Error | null, rows?: unknown, fields?: unknown[]) => void + ) { + try { + callback(null, answer(query.sql, values), []) + } catch (error) { + callback(error as Error) + } + } + } + jest.spyOn(k.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(k.client, 'releaseConnection').mockResolvedValue(undefined) + return { + k, + queries, + tables, + indexes, + triggers, + keys, + setJournaled: () => { + journaled = true + }, + progress: () => progress + } +} +afterEach(() => jest.restoreAllMocks()) + +test('MySQL owns transactional exact-collation tables, installs observers first, and locks two bootstrap batches', async () => { + const f = fixture() + try { + expect(await enabled(f.k)).toBe(false) + await install(f.k) + await install(f.k) + expect(f.triggers.size).toBe(8) + const ddl = f.queries.filter(q => q.sql.startsWith('CREATE TRIGGER')).map(q => q.sql) + expect(ddl.slice(0, 4).every(sql => sql.includes('DELETE') || sql.includes('BEFORE UPDATE'))).toBe(true) + expect(ddl[4]).toContain('snapshot_certificate_field_insert') + expect(ddl.some(sql => sql.includes('CAST(OLD.fieldName AS BINARY)'))).toBe(true) + expect(ddl.filter(sql => sql.includes('FOR SHARE'))).toHaveLength(4) + expect(f.queries.filter(q => q.sql.startsWith('create table')).every(q => q.sql.includes('engine = InnoDB'))).toBe( + true + ) + expect(f.progress()).toEqual({ + snapshotTableId: 0, + started: true, + afterFieldName: '000256', + afterCertificateId: 257, + complete: true + }) + expect(f.keys).toHaveLength(386) + expect(f.keys.find(key => key.snapshotFieldName === '000001')).toEqual({ + snapshotCertificateId: 2, + snapshotFieldName: '000001', + snapshotMembership: 3, + snapshotUserId: 1 + }) + expect(await enabled(f.k)).toBe(false) + f.setJournaled() + expect(await enabled(f.k)).toBe(true) + await remove(f.k) + await remove(f.k) + expect(f.tables).toEqual(new Set(['knex_migrations'])) + expect(f.triggers.size).toBe(0) + } finally { + await f.k.destroy() + } +}) + +test.each([ + ['sourceTables', []], + [ + 'sourceTables', + [ + { name: 'certificate_fields', engine: 'MyISAM' }, + { name: 'certificates', engine: 'InnoDB' } + ] + ], + ['sourceColumn', []], + ['sourceColumn', [{ type: 'text', nullable: 'NO', charset: 'utf8mb4', collation: 'utf8mb4_bin' }]], + ['sourceColumn', [{ type: 'varchar(100)', nullable: 'YES', charset: 'utf8mb4', collation: 'utf8mb4_bin' }]], + ['sourceColumn', [{ type: 'varchar(100)', nullable: 'NO', charset: 'invalid charset', collation: 'utf8mb4_bin' }]], + ['sourceColumn', [{ type: 'varchar(100)', nullable: 'NO', charset: 'utf8mb4', collation: 'invalid collation' }]], + ['tables', []], + ['tables', [{ engine: 'MyISAM' }]], + ['columns', []], + ['columns', null], + ['indexes', []], + ['indexes', null] +] as Array<[Kind, unknown]>)('invalid %s metadata refuses adoption and removal', async (kind, value) => { + const f = fixture((current, rows) => (current === kind ? value : rows)) + try { + await expect(install(f.k)).rejects.toThrow() + await expect(remove(f.k)).rejects.toThrow() + } finally { + await f.k.destroy() + } +}) + +test.each([ + { name: 'foreign' }, + { type: 'bigint unsigned' }, + { nullable: 'YES' }, + { defaultValue: 0 }, + { extra: 'auto_increment' }, + { charset: 'utf8mb4' }, + { collation: 'utf8mb4_bin' } +])('mismatched column %j cannot be adopted', async changed => { + const f = fixture((kind, rows) => (kind === 'columns' ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows)) + try { + await expect(install(f.k)).rejects.toThrow('table definition mismatch') + } finally { + await f.k.destroy() + } +}) + +test.each([{ columnName: 'wrong' }, { direction: 'D' }, { prefix: 10 }, { name: 'foreign_unique' }])( + 'mismatched index %j cannot be adopted', + async changed => { + const f = fixture((kind, rows) => (kind === 'indexes' ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows)) + try { + await expect(install(f.k)).rejects.toThrow('table definition mismatch') + } finally { + await f.k.destroy() + } + } +) + +test.each([{ event: 'DELETE' }, { timing: 'BEFORE' }, { tableName: 'other' }, { body: 'BEGIN SELECT 1; END' }])( + 'mismatched trigger %j refuses before dropping any trigger', + async changed => { + let corrupt = false + const f = fixture((kind, rows) => + corrupt && kind === 'triggers' && rows.length ? [{ ...rows[0], ...changed }] : rows + ) + try { + await install(f.k) + corrupt = true + await expect(install(f.k)).rejects.toThrow('trigger definition mismatch') + await expect(remove(f.k)).rejects.toThrow('trigger definition mismatch') + expect(f.triggers.size).toBe(8) + } finally { + await f.k.destroy() + } + } +) + +test.each([ + null, + [ + { event: 'INSERT', timing: 'AFTER', tableName: 'certificate_fields', body: 'BEGIN END' }, + { event: 'INSERT', timing: 'AFTER', tableName: 'certificate_fields', body: 'BEGIN END' } + ] +])('malformed trigger metadata refuses: %j', async value => { + const f = fixture((kind, rows) => (kind === 'triggers' ? value : rows)) + try { + await expect(install(f.k)).rejects.toThrow('trigger') + } finally { + await f.k.destroy() + } +}) + +test('MySQL refuses nesting migration transactions before inspecting or changing objects', async () => { + const f = fixture() + try { + Object.defineProperty(f.k, 'isTransaction', { value: true }) + await expect(install(f.k)).rejects.toThrow('independent DDL') + await expect(remove(f.k)).rejects.toThrow('independent DDL') + expect(f.queries).toEqual([]) + } finally { + await f.k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.test.ts new file mode 100644 index 000000000..408116765 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.test.ts @@ -0,0 +1,303 @@ +import type { Knex } from 'knex' +import fc from 'fast-check' +import { runInSeries } from '../../utility/runInSeries' +import { + minimalCertificateDatabase, + expectCertificateMembership, + seedCertificateFields, + applyCertificateOperation, + certificateFieldNames +} from '../../../test/utils/snapshotCertificateFixtures' +import { + addSnapshotCertificateIndexes as install, + removeSnapshotCertificateIndexes as remove, + readSnapshotCertificateIndexState as enabled, + SNAPSHOT_CERTIFICATE_INDEX_MIGRATION as migration +} from '../schema/snapshotCertificateIndexMigration' + +const databases: Knex[] = [] +async function fixture(collation = 'BINARY'): Promise { + const k = await minimalCertificateDatabase(collation) + databases.push(k) + return k +} +afterEach(async () => { + jest.restoreAllMocks() + await runInSeries(databases.splice(0), k => k.destroy()) +}) + +async function journal(k: Knex, table = 'knex_migrations'): Promise { + await k.schema.createTable(table, t => { + t.string('name') + }) + await k(table).insert({ name: migration }) +} + +test.each([0, 1, 255, 256, 257])( + 'bootstrap preserves the empty-name key for %s rows, source schema and repeat install/down', + async count => { + const k = await fixture() + if (count) { + await k('certificates').insert( + Array.from({ length: count }, (_, i) => ({ certificateId: i + 1, userId: (i % 2) + 1 })) + ) + await k('certificate_fields').insert( + Array.from({ length: count }, (_, i) => ({ + certificateId: i + 1, + userId: 1, + fieldName: '', + fieldValue: 'value' + })) + ) + } + const schema = () => + k('sqlite_master').whereIn('type', ['table', 'index']).select('name', 'type', 'sql').orderBy('name') + const before = await schema() + await install(k) + await install(k) + await expectCertificateMembership(k) + expect(await k('snapshot_certificate_index_progress').first()).toEqual({ + snapshotTableId: 0, + started: count ? 1 : 0, + afterFieldName: '', + afterCertificateId: count, + complete: 1 + }) + await remove(k) + await remove(k) + expect(await schema()).toEqual(before) + expect(await k('certificate_fields')).toHaveLength(count) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) + } +) + +test.each(['BINARY', 'NOCASE', 'RTRIM'])( + 'generated ownership, rekey and rollback schedules preserve source collation %s', + async collation => { + const k = await fixture(collation) + await seedCertificateFields(k) + await install(k) + await expectCertificateMembership(k) + const operation = fc.record({ + kind: fc.integer({ min: 0, max: 8 }), + id: fc.integer({ min: 1, max: 4 }), + user: fc.integer({ min: 1, max: 3 }), + name: fc.integer({ min: 0, max: certificateFieldNames.length - 1 }) + }) + await fc.assert( + fc.asyncProperty(fc.array(operation, { minLength: 1, maxLength: 24 }), async schedule => { + await seedCertificateFields(k) + await runInSeries(schedule, async op => { + await applyCertificateOperation(k, op) + await expectCertificateMembership(k) + }) + }), + { numRuns: 300, seed: 3242026 } + ) + } +) + +test('journal, complete positions and exact tables gate adoption in a custom migration journal', async () => { + const k = await fixture() + expect(await enabled(k)).toBe(false) + await install(k) + expect(await enabled(k)).toBe(false) + await journal(k, 'custom_journal') + const config = { tableName: 'custom_journal', schemaName: 'main' } + expect(await enabled(k, config)).toBe(true) + await k('custom_journal').update({ name: 'unrelated migration' }) + expect(await enabled(k, config)).toBe(false) + await k('custom_journal').update({ name: migration }) + await k('snapshot_certificate_index_progress').update({ complete: 0 }) + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') + await k('snapshot_certificate_index_progress').update({ complete: 1 }) + await k('snapshot_certificate_index_progress').insert({ + snapshotTableId: 1, + started: 0, + afterFieldName: '', + afterCertificateId: 0, + complete: 1 + }) + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') + await k.schema.dropTable('snapshot_certificate_index_progress') + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') + await k.schema.dropTable('snapshot_certificate_field_keys') + await expect(enabled(k, config)).rejects.toThrow('migration is incomplete') +}) + +test.each([ + { started: 2 }, + { complete: 2 }, + { afterCertificateId: -1 }, + { afterCertificateId: 1.5 }, + { afterCertificateId: Number.MAX_SAFE_INTEGER + 1 }, + { afterCertificateId: 'invalid' }, + { afterFieldName: 'x'.repeat(101) }, + { afterFieldName: 'x' }, + { afterCertificateId: 1 }, + { started: 1, afterCertificateId: 0 } +])('invalid progress %j refuses before source reads', async change => { + const k = await fixture() + await install(k) + await k('snapshot_certificate_index_progress').update({ complete: 0, ...change }) + const query = jest.fn() + k.on('query', query) + await expect(install(k)).rejects.toThrow('bootstrap position') + expect(query.mock.calls.some(([q]) => q.sql.startsWith('select `userId`, `fieldName`, `certificateId`'))).toBe(false) +}) + +test.each([ + ['userId', 0], + ['userId', -1], + ['userId', 1.5], + ['userId', 'invalid'], + ['certificateId', 0], + ['certificateId', -1], + ['certificateId', 1.5], + ['fieldName', 'x'.repeat(101)] +])('invalid source %s=%s rolls back keys and cursor', async (field, value) => { + const k = await fixture() + await k('certificate_fields').insert({ + userId: 1, + fieldName: 'a', + certificateId: 1, + fieldValue: 'v', + [field as string]: value + }) + await expect(install(k)).rejects.toThrow('source key') + expect(await k('snapshot_certificate_field_keys')).toEqual([]) + expect(await k('snapshot_certificate_index_progress').first()).toMatchObject({ + started: 0, + complete: 0, + afterCertificateId: 0 + }) +}) + +test('committed bootstrap resumes behind-cursor changes after an interrupted next batch', async () => { + const k = await fixture() + await k('certificates').insert({ certificateId: 1, userId: 1 }) + await k('certificate_fields').insert( + Array.from({ length: 300 }, (_, i) => ({ + userId: 1, + certificateId: 1, + fieldName: String(i + 1).padStart(6, '0'), + fieldValue: 'v' + })) + ) + const failure = new Error('synthetic certificate migration interruption') + let reads = 0 + const interrupt = (q: { sql: string }): void => { + if (q.sql.startsWith('select `userId`, `fieldName`, `certificateId`') && ++reads === 2) throw failure + } + k.on('query', interrupt) + try { + await expect(install(k)).rejects.toBe(failure) + } finally { + k.off('query', interrupt) + } + expect(await k('snapshot_certificate_index_progress').first()).toMatchObject({ + started: 1, + afterFieldName: '000256', + afterCertificateId: 1, + complete: 0 + }) + expect(await k('snapshot_certificate_field_keys')).toHaveLength(256) + await k('certificate_fields').insert({ userId: 2, certificateId: 1, fieldName: '', fieldValue: 'late' }) + await k('certificates').update({ userId: 3 }) + await k('certificate_fields').where('fieldName', '000001').delete() + await install(k) + await expectCertificateMembership(k) +}) + +test('changed triggers refuse install and removal before altering any source or trigger', async () => { + const k = await fixture() + await install(k) + await k.raw('DROP TRIGGER snapshot_certificate_field_insert') + await k.raw('CREATE TRIGGER snapshot_certificate_field_insert AFTER INSERT ON certificate_fields BEGIN SELECT 1; END') + const before = await k('sqlite_master').where('type', 'trigger').orderBy('name') + await expect(install(k)).rejects.toThrow('trigger definition mismatch') + await expect(remove(k)).rejects.toThrow('trigger definition mismatch') + expect(await k('sqlite_master').where('type', 'trigger').orderBy('name')).toEqual(before) +}) + +test.each([ + 'CREATE UNIQUE INDEX foreign_certificate ON snapshot_certificate_field_keys(snapshotCertificateId)', + 'ALTER TABLE snapshot_certificate_field_keys ADD COLUMN unexpected INTEGER', + 'DROP INDEX snapshot_certificate_parent' +])('schema inspection detects incompatible definitions: %s', async sql => { + const k = await fixture() + await install(k) + await journal(k) + await k.raw(sql) + await expect(enabled(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') + if (sql.startsWith('DROP')) { + await install(k) + expect(await enabled(k)).toBe(true) + } else await expect(install(k)).rejects.toThrow('table definition mismatch') +}) + +test.each([ + 'snapshotCertificateId DESC,snapshotUserId,snapshotFieldName', + 'snapshotCertificateId,snapshotUserId,snapshotFieldName COLLATE NOCASE' +])('incompatible maintenance index %s refuses adoption', async columns => { + const k = await fixture() + await install(k) + await k.raw('DROP INDEX snapshot_certificate_parent') + await k.raw('CREATE INDEX snapshot_certificate_parent ON snapshot_certificate_field_keys(' + columns + ')') + await expect(install(k)).rejects.toThrow('table definition mismatch') +}) + +test('a separately collated unique index cannot redefine the source cursor order', async () => { + const k = await fixture() + await k.schema.dropTable('certificate_fields') + await k.raw( + 'CREATE TABLE certificate_fields(userId INTEGER,fieldName VARCHAR(100),certificateId INTEGER,fieldValue TEXT)' + ) + await k.raw('CREATE UNIQUE INDEX wrong_order ON certificate_fields(fieldName COLLATE NOCASE,certificateId)') + await expect(install(k)).rejects.toThrow('field order') + expect(await k.schema.hasTable('snapshot_certificate_field_keys')).toBe(false) +}) + +test('bootstrap retains orphan fields as directly owned without inventing parent membership', async () => { + const k = await fixture() + await k('certificate_fields').insert({ userId: 7, fieldName: 'orphan', certificateId: 99, fieldValue: 'synthetic' }) + await install(k) + await expectCertificateMembership(k) + expect(await k('snapshot_certificate_field_keys')).toEqual([ + { snapshotUserId: 7, snapshotFieldName: 'orphan', snapshotCertificateId: 99, snapshotMembership: 1 } + ]) +}) + +test.each([ + 'fieldName', + 'certificateId,fieldName', + 'fieldName,certificateId DESC', + 'fieldName,certificateId COLLATE NOCASE' +])('incompatible source unique index %s refuses before installing objects', async columns => { + const k = await fixture() + await k.schema.dropTable('certificate_fields') + await k.raw( + 'CREATE TABLE certificate_fields(userId INTEGER,fieldName VARCHAR(100),certificateId INTEGER,fieldValue TEXT)' + ) + await k.raw('CREATE UNIQUE INDEX wrong_order ON certificate_fields(' + columns + ')') + await k.raw('CREATE INDEX nonunique_order ON certificate_fields(fieldName,certificateId)') + await k.raw('CREATE UNIQUE INDEX partial_order ON certificate_fields(fieldName,certificateId) WHERE userId=1') + await expect(install(k)).rejects.toThrow('field order') + expect(await k.schema.hasTable('snapshot_certificate_field_keys')).toBe(false) +}) + +test('missing auxiliary primary-index metadata refuses without publishing progress', async () => { + const k = await fixture() + const omit = (rows: unknown, query: { sql: string }): void => { + if (query.sql.startsWith('PRAGMA index_list(') && query.sql.includes('snapshot_certificate_field_keys')) + (rows as unknown[]).splice(0) + } + k.on('query-response', omit) + try { + await expect(install(k)).rejects.toThrow('table definition mismatch') + } finally { + k.off('query-response', omit) + } + expect(await k.schema.hasTable('snapshot_certificate_index_progress')).toBe(false) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts index d38206f56..8b477e533 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts @@ -5,7 +5,7 @@ import { knex } from 'knex' import { StorageKnex } from '../StorageKnex' import { StorageProvider } from '../StorageProvider' import { KnexMigrations } from '../schema/KnexMigrations' -import { SNAPSHOT_RELATION_INDEX_MIGRATION } from '../schema/snapshotRelationIndexMigration' +import { SNAPSHOT_CERTIFICATE_INDEX_MIGRATION } from '../schema/snapshotCertificateIndexMigration' import { readSnapshotProfileIndexState, SNAPSHOT_PROFILE_INDEX_MIGRATION @@ -65,7 +65,7 @@ test('registered profile bootstrap survives reopening and publishes its journal database = knex(options) source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: database }) await expect(source.migrate('profile migration', 'synthetic-profile-migration')).resolves.toBe( - SNAPSHOT_RELATION_INDEX_MIGRATION + SNAPSHOT_CERTIFICATE_INDEX_MIGRATION ) expect(await readSnapshotProfileIndexState(database)).toBe(true) expect(await database('snapshot_profile_keys').where('snapshotTableId', 3).count({ count: '*' }).first()).toEqual({ diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index ad9669432..5944cb5c6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' -import { SNAPSHOT_RELATION_INDEX_MIGRATION } from '../../schema/KnexMigrations' +import { SNAPSHOT_CERTIFICATE_INDEX_MIGRATION } from '../../schema/KnexMigrations' import { decodeSyncTransfer } from '../../remoting/SyncTransfer' import * as Transfer from '../../remoting/SyncTransfer' import * as ArchiveSource from './KnexSnapshotArchiveSource' @@ -72,7 +72,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { onProgress: p => progress.push(p) }) - expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_RELATION_INDEX_MIGRATION) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_CERTIFICATE_INDEX_MIGRATION) expect(manifest.binding.sourceStorage.storageName).toBe('original source') expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) @@ -84,7 +84,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof sourceStorageIdentityKey: 'original-source', archiveId: manifest.archiveId, digest: manifest.digest, - sourceSchema: SNAPSHOT_RELATION_INDEX_MIGRATION + sourceSchema: SNAPSHOT_CERTIFICATE_INDEX_MIGRATION }) expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} @@ -153,7 +153,7 @@ test('source schema, primary history and closure stay pinned while an independen await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) - expect(source.sourceSchema).toBe(SNAPSHOT_RELATION_INDEX_MIGRATION) + expect(source.sourceSchema).toBe(SNAPSHOT_CERTIFICATE_INDEX_MIGRATION) expect(source.user.activeStorage).toBe(originalPrimary) await expect(source.validateClosure()).resolves.toBeUndefined() expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts index 023dc374c..7b7242534 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts @@ -128,7 +128,8 @@ export async function assertKnexSnapshotArchiveClosure( k: Knex, userId: number, profileIndexes = false, - relationIndexes = false + relationIndexes = false, + certificateIndexes = false ): Promise { if (!Number.isSafeInteger(userId) || userId < 1) throw new WERR_INVALID_PARAMETER('userId', 'a positive safe ID') await runInSeries(references, async reference => { @@ -137,7 +138,14 @@ export async function assertKnexSnapshotArchiveClosure( .select(k.raw('1')) .whereRaw('?? = ??', [`${reference.target}.${reference.key}`, column]) if (reference.profile) void target.where(`${reference.target}.userId`, userId) - const invalid = walletSnapshotSourceQuery(k, reference.table, userId, profileIndexes, relationIndexes) + const invalid = walletSnapshotSourceQuery( + k, + reference.table, + userId, + profileIndexes, + relationIndexes, + certificateIndexes + ) .select(k.raw('1 AS invalid')) .whereNotExists(target) if (reference.optional === true) void invalid.whereNotNull(column) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index 915d02a6c..d43572604 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -1,3 +1,4 @@ +import { readSnapshotCertificateIndexState } from '../../schema/snapshotCertificateIndexMigration' import { readSnapshotRelationIndexState } from '../../schema/snapshotRelationIndexMigration' import { readSnapshotProfileIndexState } from '../../schema/snapshotProfileIndexMigration' import { Random, Utils } from '@bsv/sdk' @@ -52,7 +53,7 @@ export async function openKnexSnapshotArchiveSource( } const view = await openView() try { - const { header, profileIndexes, relationIndexes } = await view.read(async trx => { + const { header, profileIndexes, relationIndexes, certificateIndexes } = await view.read(async trx => { const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') @@ -63,7 +64,11 @@ export async function openKnexSnapshotArchiveSource( sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) }, profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - relationIndexes: await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations) + relationIndexes: await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations), + certificateIndexes: await readSnapshotCertificateIndexState( + storage.toDb(trx), + storage.knex.client.config.migrations + ) } }) const userId = header.user.userId @@ -78,10 +83,24 @@ export async function openKnexSnapshotArchiveSource( }, closed: view.closed, close: view.close, - readPage: createKnexWalletSnapshotPageReader(storage, userId, snapshotId, view, profileIndexes, relationIndexes), + readPage: createKnexWalletSnapshotPageReader( + storage, + userId, + snapshotId, + view, + profileIndexes, + relationIndexes, + certificateIndexes + ), validateClosure: async () => { await view.read(trx => - assertKnexSnapshotArchiveClosure(storage.toDb(trx), userId, profileIndexes, relationIndexes) + assertKnexSnapshotArchiveClosure( + storage.toDb(trx), + userId, + profileIndexes, + relationIndexes, + certificateIndexes + ) ) } } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index cb5daefc1..9e843ffac 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -701,7 +701,7 @@ test('only acknowledged sequence positions are readable, even if an unacknowledg test('the auxiliary migration is registered after the durable sync schema', async () => { const migrations = new KnexMigrations('test', 'source', 'source', 1024) - expect(await migrations.getLatestMigration()).toBe('2026-10-01-004 add snapshot relation key indexes') + expect(await migrations.getLatestMigration()).toBe('2026-10-01-005 add snapshot certificate field key indexes') }) test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts index 1d74bb103..b9d25826c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts @@ -33,7 +33,7 @@ test.each([StorageClient, StorageMobile])( expect(storage.getSettings()).not.toHaveProperty('snapshotArchive') let transport = (await client.getSnapshotArchiveTransport(identityKey))! const offer = await transport.offer() - expect(offer.sourceSchema).toBe('2026-10-01-004 add snapshot relation key indexes') + expect(offer.sourceSchema).toBe('2026-10-01-005 add snapshot certificate field key indexes') expect(Math.abs(offer.serverTime - Date.now())).toBeLessThan(5000) const fields = { version: 1 as const, diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs index 67ecccb5a..450800ab5 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs @@ -167,6 +167,8 @@ async function main() { console.log(JSON.stringify({ profileIndexProcessLoss: await qualifySQLiteProfileIndexProcessLoss() })) const { qualifySQLiteRelationIndexProcessLoss } = require('./snapshotRelationIndexCrash.cjs') console.log(JSON.stringify({ relationIndexProcessLoss: await qualifySQLiteRelationIndexProcessLoss() })) + const { qualifySQLiteCertificateIndexProcessLoss } = require('./snapshotCertificateIndexCrash.cjs') + console.log(JSON.stringify({ certificateIndexProcessLoss: await qualifySQLiteCertificateIndexProcessLoss() })) } } main().catch(error => { diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index 6dbbbdf60..119621acf 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -145,7 +145,7 @@ async function captureFixture() { assert.equal(manifest.pages, 14) assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') assert.equal(manifest.binding.user.activeStorage, 'historical selection') - assert.equal(manifest.binding.sourceSchema, '2026-10-01-004 add snapshot relation key indexes') + assert.equal(manifest.binding.sourceSchema, '2026-10-01-005 add snapshot certificate field key indexes') const store = new KnexSnapshotArchiveStore(writer.knex) const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) @@ -433,7 +433,7 @@ async function requestFixture(writer, reader) { sourceStorageIdentityKey: 'native-source', digest: ready.digest }) - assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-004 add snapshot relation key indexes') + assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-005 add snapshot certificate field key indexes') const page = await replacement.read(identity, ready.archiveId, 8) const decoded = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[8])) assert.equal(decoded.rows[0].label, 'replacement') @@ -544,6 +544,14 @@ async function main() { const relationIndexLocks = await qualifyMysqlRelationIndexLocks(database, connection) const { qualifyMysqlRelationIndexSeeks } = require('./snapshotRelationIndexSeeks.cjs') const relationIndexSeeks = await qualifyMysqlRelationIndexSeeks(database, connection) + const { qualifyMysqlCertificateIndexProcessLoss } = require('./snapshotCertificateIndexCrash.cjs') + const certificateIndexProcessLoss = await qualifyMysqlCertificateIndexProcessLoss(database, connection) + const { qualifyMysqlCertificateIndexLocks } = require('./snapshotCertificateIndexMysql.cjs') + const certificateIndexLocks = await qualifyMysqlCertificateIndexLocks(database, connection) + const { qualifyMysqlCertificateIndexSchedules } = require('./snapshotCertificateIndexMysqlSchedules.cjs') + const certificateIndexSchedules = await qualifyMysqlCertificateIndexSchedules(database, connection) + const { qualifyMysqlCertificateIndexSeeks } = require('./snapshotCertificateIndexSeeks.cjs') + const certificateIndexSeeks = await qualifyMysqlCertificateIndexSeeks(database, connection) console.log( JSON.stringify({ version, @@ -561,6 +569,10 @@ async function main() { relationIndexProcessLoss, relationIndexLocks, relationIndexSeeks, + certificateIndexProcessLoss, + certificateIndexLocks, + certificateIndexSchedules, + certificateIndexSeeks, capture }) ) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexCrash.cjs new file mode 100644 index 000000000..e28b26eb5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexCrash.cjs @@ -0,0 +1,256 @@ +// Synthetic process-loss qualification, invoked by the existing native fixtures. +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { randomUUID } = require('node:crypto') +const fs = require('node:fs/promises') +const { writeFileSync } = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { KnexMigrations } = require('../../out/src/storage/schema/KnexMigrations.js') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + readSnapshotCertificateIndexState, + SNAPSHOT_CERTIFICATE_INDEX_MIGRATION +} = require('../../out/src/storage/schema/snapshotCertificateIndexMigration.js') + +const phases = [ + 'after-key-table', + 'after-first-index', + 'partial-observers', + 'partial-producers', + 'before-cursor', + 'after-cursor', + 'after-commit' +] +const migrationName = 'synthetic certificate crash' +const migrationIdentity = 'synthetic-certificate-crash' +const { oracle } = require('./snapshotCertificateIndexMysqlSchedules.cjs') +const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } +const provider = options => + new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: knex(options) }) + +async function seed(options) { + const source = provider(options) + try { + if (options.client === 'better-sqlite3') await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate(migrationName, migrationIdentity) + await source.makeAvailable() + const migrationSource = new KnexMigrations('test', migrationName, migrationIdentity, 1024) + await source.knex.migrate.down({ + migrationSource, + name: SNAPSHOT_CERTIFICATE_INDEX_MIGRATION, + disableTransactions: false + }) + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await source.knex('certificates').insert({ + ...dates, + certificateId: 1, + userId: user.userId, + serialNumber: 'synthetic-certificate', + type: 'type', + certifier: '02' + '11'.repeat(32), + subject: '02' + '11'.repeat(32), + revocationOutpoint: 'a'.repeat(64) + '.0', + signature: 'synthetic', + isDeleted: false + }) + await runInSeries([0, 1, 2], async batch => { + await source.knex('certificate_fields').insert( + Array.from({ length: 200 }, (_, i) => ({ + ...dates, + userId: user.userId, + certificateId: 1, + fieldName: String(batch * 200 + i).padStart(6, '0'), + fieldValue: 'synthetic', + masterKey: 'synthetic' + })) + ) + }) + return { userId: user.userId, otherId: other.userId } + } finally { + await source.destroy() + } +} + +async function child(options, phase, marker) { + assert(phases.includes(phase)) + assert.equal(typeof process.send, 'function', 'Child requires its fixture parent') + const source = provider(options) + let cursorWritten = false + const cursor = query => + query.sql.startsWith('update `snapshot_certificate_index_progress`') && query.bindings.includes('000255') + const park = event => { + writeFileSync(marker, JSON.stringify({ phase, event }), { mode: 0o600 }) + process.kill(process.pid, 'SIGKILL') + } + source.knex.on('query', query => { + if (phase === 'before-cursor' && cursor(query)) park('query') + if (phase === 'after-commit' && cursorWritten && query.sql.toLowerCase().startsWith('begin')) + park('next-transaction') + }) + source.knex.on('query-response', (_result, query) => { + const sql = query.sql.toLowerCase() + if (phase === 'after-key-table' && sql.startsWith('create table `snapshot_certificate_field_keys`')) + park('query-response') + if ( + phase === 'after-first-index' && + (sql.startsWith('create index `snapshot_certificate_parent`') || + sql.startsWith('alter table `snapshot_certificate_field_keys` add index `snapshot_certificate_parent`')) + ) + park('query-response') + if (phase === 'partial-observers' && sql.startsWith('create trigger snapshot_certificate_field_delete ')) + park('query-response') + if (phase === 'partial-producers' && sql.startsWith('create trigger snapshot_certificate_field_insert ')) + park('query-response') + if (cursor(query)) { + cursorWritten = true + if (phase === 'after-cursor') park('query-response') + } + if (phase === 'after-commit' && cursorWritten && sql.startsWith('commit')) park('query-response') + }) + try { + await source.migrate(migrationName, migrationIdentity) + throw new Error('Expected migration boundary was not reached') + } finally { + await source.destroy() + } +} + +async function terminateAt(options, phase) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-certificate-boundary-')) + const marker = path.join(directory, 'boundary.json') + const processHandle = spawn(process.execPath, [__filename, 'child'], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + let stderr = '' + processHandle.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-65536) + }) + const exited = new Promise((resolve, reject) => { + processHandle.once('exit', (code, signal) => resolve({ code, signal })) + processHandle.once('error', reject) + }) + const timer = setTimeout(() => processHandle.kill('SIGKILL'), 15000) + try { + processHandle.send({ options, phase, marker }) + const result = await exited + assert.equal(result.signal, 'SIGKILL', stderr) + const observed = JSON.parse(await fs.readFile(marker, 'utf8')) + assert.equal(observed.phase, phase) + return { phase, event: observed.event, signal: result.signal } + } finally { + clearTimeout(timer) + if (processHandle.exitCode === null && processHandle.signalCode === null) { + processHandle.kill('SIGKILL') + await exited + } + await fs.rm(directory, { recursive: true, force: true }) + } +} + +async function qualify(options, phase) { + const { userId, otherId } = await seed(options) + const outcome = await terminateAt(options, phase) + const source = provider(options) + const database = source.knex + try { + assert.equal(await readSnapshotCertificateIndexState(database), false) + if (['before-cursor', 'after-cursor', 'after-commit'].includes(phase)) { + const committed = phase === 'after-commit' + const progress = await database('snapshot_certificate_index_progress').where('snapshotTableId', 0).first() + assert.equal(progress.afterFieldName, committed ? '000255' : '') + assert.equal(progress.started, committed ? 1 : 0) + assert.equal(progress.afterCertificateId, committed ? 1 : 0) + assert.equal( + Number((await database('snapshot_certificate_field_keys').count({ count: '*' }).first()).count), + committed ? 256 : 0 + ) + } + await database('certificates').where('certificateId', 1).update({ userId: otherId }) + await database('certificate_fields').where('fieldName', '000004').delete() + await database('certificate_fields').insert({ + ...dates, + userId, + certificateId: 1, + fieldName: '000003a', + fieldValue: 'inserted-behind-cursor', + masterKey: 'synthetic' + }) + // A killed migrator leaves Knex's lock claimed. This is fixture-owned recovery; + // the verified child is gone and no other migrator can own this isolated store. + await database.migrate.forceFreeMigrationsLock() + await source.migrate(migrationName, migrationIdentity) + assert.equal(await readSnapshotCertificateIndexState(database), true) + await oracle(database) + assert.equal( + Number( + ( + await database('knex_migrations') + .where('name', SNAPSHOT_CERTIFICATE_INDEX_MIGRATION) + .count({ count: '*' }) + .first() + ).count + ), + 1 + ) + return { ...outcome, journalPublishedAfterRecovery: true, independentProfileChangeAndLowIdInsert: true } + } finally { + await source.destroy() + } +} + +async function qualifySQLiteCertificateIndexProcessLoss() { + const results = [] + await runInSeries(phases, async phase => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-certificate-index-crash-')) + try { + results.push( + await qualify( + { + client: 'better-sqlite3', + connection: { filename: path.join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }, + phase + ) + ) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + }) + return results +} + +async function qualifyMysqlCertificateIndexProcessLoss(control, connection) { + // The calling fixture has already verified its disposable, pinned container. + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const results = [] + await runInSeries(phases, async phase => { + const database = 'ts569_certificate_' + randomUUID().replaceAll('-', '') + await control.raw('CREATE DATABASE ??', [database]) + try { + results.push( + await qualify({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }, phase) + ) + } finally { + await control.raw('DROP DATABASE ??', [database]) + } + }) + return results +} + +if (process.argv[2] === 'child') { + assert.equal(typeof process.send, 'function', 'Child execution requires its fixture parent') + process.once('message', ({ options, phase, marker }) => { + child(options, phase, marker).catch(error => { + console.error(error) + process.exitCode = 1 + process.disconnect() + }) + }) +} +module.exports = { qualifySQLiteCertificateIndexProcessLoss, qualifyMysqlCertificateIndexProcessLoss } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysql.cjs new file mode 100644 index 000000000..b4b3e025d --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysql.cjs @@ -0,0 +1,229 @@ +// Synthetic current-read and bootstrap-lock qualification on the native MySQL fixture. +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex } = require('knex') +const { oracle } = require('./snapshotCertificateIndexMysqlSchedules.cjs') +const helper = require('../../out/src/storage/schema/snapshotCertificateIndexMigration.js') +const install = helper.addSnapshotCertificateIndexes +const bootstrap = install +const settle = promise => + promise.then( + value => ({ ok: true, value }), + error => ({ ok: false, error }) + ) +const success = result => { + if (!result.ok) throw result.error +} +async function until(check) { + for (let i = 0; i < 1000; i++) { + const result = await check() + if (result) return result + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('Native lock observation exceeded five seconds') +} +async function fixture(admin, connection, isolation) { + const database = 'ts569_cert_locks_' + randomUUID().replaceAll('-', '') + await admin.raw('CREATE DATABASE ?? CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci', [database]) + const open = () => knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + const k = open(), + writer = open(), + observer = open(), + events = [] + let transaction + try { + for (const client of [k, writer, observer]) + await client.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation.toUpperCase()) + await k.raw( + 'CREATE TABLE certificates(certificateId INT UNSIGNED PRIMARY KEY,userId INT UNSIGNED NOT NULL,isDeleted TINYINT NOT NULL DEFAULT 0) ENGINE=InnoDB' + ) + await k.raw( + 'CREATE TABLE certificate_fields(userId INT UNSIGNED NOT NULL,fieldName VARCHAR(100) NOT NULL,certificateId INT UNSIGNED NOT NULL,fieldValue VARCHAR(255) NOT NULL,UNIQUE(fieldName,certificateId)) ENGINE=InnoDB' + ) + await k('certificates').insert({ certificateId: 1, userId: 1 }) + await install(k) + const field = { userId: 1, fieldName: 'A', certificateId: 1, fieldValue: 'synthetic' } + const reset = async () => { + await k('certificate_fields').delete() + await k('certificates').where({ certificateId: 1 }).update({ userId: 1 }) + } + const verify = async () => { + await oracle(k) + assert.deepEqual( + await k('snapshot_certificate_field_keys') + .select({ + userId: 'snapshotUserId', + fieldName: 'snapshotFieldName', + certificateId: 'snapshotCertificateId', + membership: 'snapshotMembership' + }) + .orderBy('snapshotUserId'), + [ + { userId: 1, fieldName: 'A', certificateId: 1, membership: 1 }, + { userId: 2, fieldName: 'A', certificateId: 1, membership: 2 } + ] + ) + } + const id = async client => Number((await client.raw('SELECT CONNECTION_ID() AS id'))[0][0].id) + const kId = await id(k), + writerId = await id(writer) + const waiting = async requester => { + const [rows] = await observer.raw( + 'SELECT l.OBJECT_NAME AS tableName,l.LOCK_MODE AS lockMode,t.PROCESSLIST_ID AS requester FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID AND l.ENGINE=w.ENGINE JOIN performance_schema.threads t ON t.THREAD_ID=l.THREAD_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND t.PROCESSLIST_ID=?', + [requester] + ) + return rows.length ? rows : false + } + transaction = await writer.transaction() + await transaction('certificates').where({ certificateId: 1 }).update({ userId: 2 }) + let finished = false + const first = settle( + k('certificate_fields') + .insert(field) + .then(() => { + finished = true + }) + ) + try { + const locks = await until(() => waiting(kId)) + assert.equal(finished, false) + events.push({ case: 'field publication waits for current parent ownership', locks }) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + success(await first) + await verify() + + await reset() + transaction = await writer.transaction() + await transaction('certificate_fields').insert(field) + finished = false + const second = settle( + k('certificates') + .where({ certificateId: 1 }) + .update({ userId: 2 }) + .then(() => { + finished = true + }) + ) + try { + const locks = await until(() => waiting(kId)) + assert.equal(finished, false) + assert.ok(locks.some(row => row.tableName === 'certificates')) + events.push({ case: 'parent update waits for field publication', locks }) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + success(await second) + await verify() + + await reset() + transaction = await k.transaction() + try { + assert.equal((await transaction('certificates').first()).userId, 1) + await writer('certificates').where({ certificateId: 1 }).update({ userId: 2 }) + await transaction('certificate_fields').insert(field) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + await verify() + events.push({ case: 'field producer ignores earlier consistent parent snapshot' }) + + await reset() + transaction = await k.transaction() + try { + assert.equal((await transaction('snapshot_certificate_field_keys')).length, 0) + await writer('certificate_fields').insert(field) + await transaction('certificates').where({ certificateId: 1 }).update({ userId: 2 }) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + await verify() + events.push({ case: 'parent producer uses current direct keys after an earlier consistent snapshot' }) + + const clearBootstrap = async () => { + await k('snapshot_certificate_field_keys').delete() + await k('snapshot_certificate_index_progress') + .where({ snapshotTableId: 0 }) + .update({ started: 0, afterFieldName: '', afterCertificateId: 0, complete: 0 }) + } + await clearBootstrap() + transaction = await writer.transaction() + await transaction('certificates').where({ certificateId: 1 }).update({ userId: 1 }) + await transaction('certificates').where({ certificateId: 1 }).update({ userId: 2 }) + const restoring = settle(bootstrap(k)) + try { + const locks = await until(() => waiting(kId)) + events.push({ case: 'bootstrap waits for current committed parent ownership', locks }) + await transaction.commit() + } finally { + if (!transaction.isCompleted()) await transaction.rollback() + } + success(await restoring) + await verify() + + await k('certificates').where({ certificateId: 1 }).update({ userId: 1 }) + await clearBootstrap() + let reached = false, + release + const gate = new Promise(resolve => { + release = resolve + }) + const prototype = Object.getPrototypeOf(k.client), + original = prototype.query, + hadOwn = Object.hasOwn(prototype, 'query') + prototype.query = async function (connection, query) { + const sql = typeof query === 'string' ? query : query.sql + if (!reached && sql.startsWith('update `snapshot_certificate_index_progress`')) { + reached = true + await gate + } + return await original.call(this, connection, query) + } + const pending = settle(bootstrap(k)) + let change + try { + await until(async () => reached) + finished = false + change = settle( + writer('certificates') + .where({ certificateId: 1 }) + .update({ userId: 2 }) + .then(() => { + finished = true + }) + ) + const locks = await until(() => waiting(writerId)) + assert.equal(finished, false) + assert.ok(locks.some(row => row.tableName === 'certificates')) + events.push({ case: 'bootstrap retains parent protection through key and cursor commit', locks }) + } finally { + release() + if (hadOwn) prototype.query = original + else delete prototype.query + success(await pending) + if (change !== undefined) success(await change) + } + await verify() + return { isolation, events } + } finally { + if (transaction && !transaction.isCompleted()) await transaction.rollback() + await observer.destroy() + await writer.destroy() + await k.destroy() + await admin.raw('DROP DATABASE ??', [database]) + } +} +async function qualifyMysqlCertificateIndexLocks(control, connection) { + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const results = [] + for (const isolation of ['read committed', 'repeatable read']) + results.push(await fixture(control, connection, isolation)) + return results +} +module.exports = { qualifyMysqlCertificateIndexLocks } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysqlSchedules.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysqlSchedules.cjs new file mode 100644 index 000000000..a52f4b6ca --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysqlSchedules.cjs @@ -0,0 +1,169 @@ +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex } = require('knex') +const fc = require('fast-check') +const helper = require('../../out/src/storage/schema/snapshotCertificateIndexMigration.js') +const names = ['', 'A', 'a', 'é', 'e\u0301', 'Ω', '名字', '🚲', 'a ', 'A\0B', '😀'.repeat(100)] +async function oracle(k) { + const result = await k.raw( + 'SELECT userId,fieldName,certificateId,SUM(bit) AS membership FROM (SELECT userId,fieldName,certificateId,1 AS bit FROM certificate_fields UNION ALL SELECT c.userId,f.fieldName,f.certificateId,2 AS bit FROM certificate_fields f JOIN certificates c ON c.certificateId=f.certificateId) owned_fields GROUP BY userId,fieldName,certificateId ORDER BY userId,fieldName,certificateId' + ) + const expected = (k.client.config.client === 'mysql2' ? result[0] : result).map(row => ({ + ...row, + membership: Number(row.membership) + })) + const actual = await k('snapshot_certificate_field_keys') + .select({ + userId: 'snapshotUserId', + fieldName: 'snapshotFieldName', + certificateId: 'snapshotCertificateId', + membership: 'snapshotMembership' + }) + .orderBy(['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId']) + assert.deepEqual(actual, expected) +} +async function seed(k) { + await k('certificate_fields').delete() + await k('certificates').delete() + await k('certificates').insert([ + { certificateId: 1, userId: 1 }, + { certificateId: 2, userId: 2 } + ]) + for (const [i, fieldName] of names.entries()) + await k('certificate_fields') + .insert({ userId: (i % 2) + 1, fieldName, certificateId: (i % 2) + 1, fieldValue: 'initial' }) + .onConflict(['fieldName', 'certificateId']) + .merge(['userId', 'fieldValue']) +} +async function operation(k, op, counts) { + counts[op.kind]++ + const fieldName = names[op.name], + certificateId = op.id + const selected = () => k('certificate_fields').where({ fieldName, certificateId }) + switch (op.kind) { + case 0: + await k('certificates').insert({ certificateId, userId: op.user }).onConflict('certificateId').merge(['userId']) + break + case 1: + await k('certificates').where({ certificateId }).delete() + break + case 2: + await k('certificate_fields') + .insert({ userId: op.user, fieldName, certificateId, fieldValue: 'value-' + op.user }) + .onConflict(['fieldName', 'certificateId']) + .merge(['userId', 'fieldValue']) + break + case 3: + await selected().delete() + break + case 4: + await selected().update({ userId: op.user }) + break + case 5: + if ( + !(await k('certificates') + .where({ certificateId: certificateId + 4 }) + .first()) + ) + await k('certificates') + .where({ certificateId }) + .update({ certificateId: certificateId + 4 }) + break + case 6: + try { + await selected().update({ fieldName: names[(op.name + 1) % names.length] }) + } catch (error) { + if (!['ER_DUP_ENTRY', 'SQLITE_CONSTRAINT_UNIQUE'].includes(error.code)) throw error + } + break + case 7: + await assert.rejects( + k.transaction(async trx => { + await trx('certificate_fields').where({ fieldName, certificateId }).delete() + throw new Error('synthetic operation rollback') + }), + /synthetic operation rollback/ + ) + break + case 8: + await k('certificates') + .where({ certificateId }) + .update({ isDeleted: k.raw('1-isDeleted') }) + break + } +} +async function qualify(k, collation) { + const mysql = k.client.config.client === 'mysql2' + await k.raw( + mysql + ? 'CREATE TABLE certificates(certificateId INT UNSIGNED PRIMARY KEY,userId INT UNSIGNED NOT NULL,isDeleted TINYINT NOT NULL DEFAULT 0) ENGINE=InnoDB' + : 'CREATE TABLE certificates(certificateId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,isDeleted INTEGER NOT NULL DEFAULT 0)' + ) + await k.raw( + mysql + ? 'CREATE TABLE certificate_fields(userId INT UNSIGNED NOT NULL,fieldName VARCHAR(100) NOT NULL,certificateId INT UNSIGNED NOT NULL,fieldValue VARCHAR(255) NOT NULL,UNIQUE(fieldName,certificateId)) ENGINE=InnoDB' + : `CREATE TABLE certificate_fields(userId INTEGER NOT NULL,fieldName VARCHAR(100) COLLATE ${collation} NOT NULL,certificateId INTEGER NOT NULL,fieldValue VARCHAR(255) NOT NULL,UNIQUE(fieldName,certificateId))` + ) + await seed(k) + assert.equal(await helper.readSnapshotCertificateIndexState(k), false) + await helper.addSnapshotCertificateIndexes(k) + await oracle(k) + await helper.addSnapshotCertificateIndexes(k) + await oracle(k) + assert.equal(await helper.readSnapshotCertificateIndexState(k), false) + await k.schema.createTable('knex_migrations', t => t.string('name')) + await k('knex_migrations').insert({ name: helper.SNAPSHOT_CERTIFICATE_INDEX_MIGRATION }) + assert.equal(await helper.readSnapshotCertificateIndexState(k), true) + const generated = fc.record({ + kind: fc.integer({ min: 0, max: 8 }), + id: fc.integer({ min: 1, max: 4 }), + user: fc.integer({ min: 1, max: 3 }), + name: fc.integer({ min: 0, max: names.length - 1 }) + }) + const counts = Array(9).fill(0) + await fc.assert( + fc.asyncProperty(fc.array(generated, { minLength: 1, maxLength: 24 }), async schedule => { + await seed(k) + for (const op of schedule) { + await operation(k, op, counts) + await oracle(k) + } + }), + { numRuns: 300, seed: 3242026 } + ) + const sourceRows = await k('certificate_fields').orderBy(['fieldName', 'certificateId']) + await helper.removeSnapshotCertificateIndexes(k) + assert.deepEqual(await k('certificate_fields').orderBy(['fieldName', 'certificateId']), sourceRows) + await assert.rejects(helper.readSnapshotCertificateIndexState(k), /incomplete/) + await helper.addSnapshotCertificateIndexes(k) + await oracle(k) + assert.equal(await helper.readSnapshotCertificateIndexState(k), true) + return { + dialect: mysql ? 'mysql' : 'sqlite', + collation, + runs: 300, + seed: 3242026, + counts, + repeatedInstall: true, + journalAdoption: true, + removalPreservesSource: true + } +} +async function qualifyMysqlCertificateIndexSchedules(control, connection) { + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const results = [] + for (const collation of ['utf8mb4_0900_ai_ci', 'utf8mb4_unicode_ci', 'utf8mb4_bin']) { + const database = 'ts569_certificate_schedules_' + randomUUID().replaceAll('-', '') + await control.raw(`CREATE DATABASE ?? CHARACTER SET utf8mb4 COLLATE ${collation}`, [database]) + const k = knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + try { + results.push(await qualify(k, collation)) + } finally { + await k.destroy() + await control.raw('DROP DATABASE ??', [database]) + } + } + return results +} +module.exports = { oracle, qualifyMysqlCertificateIndexSchedules } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexSeeks.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexSeeks.cjs new file mode 100644 index 000000000..aa25e7857 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexSeeks.cjs @@ -0,0 +1,200 @@ +// Invoked only by the verified disposable native MySQL fixture. +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + createKnexWalletSnapshotPageReader: pageReader +} = require('../../out/src/storage/snapshot/KnexWalletReadSnapshot.js') + +async function seedLargeFields(k, userId, otherId) { + const date = new Date('2026-01-01T00:00:00Z') + await runInSeries( + Array.from({ length: 32 }, (_, i) => i * 256), + async start => { + await k('certificate_fields').insert( + Array.from({ length: 256 }, (_, i) => { + const id = start + i + return { + created_at: date, + updated_at: date, + fieldName: String(id).padStart(6, '0'), + userId: id % 2 ? otherId : userId, + certificateId: id % 2 ? 2 : 1, + fieldValue: 'synthetic', + masterKey: 'synthetic' + } + }) + ) + } + ) +} + +async function handlerCounters(source, view) { + return await view.read(async trx => + Object.fromEntries( + (await source.toDb(trx).raw("SHOW SESSION STATUS LIKE 'Handler_read_%'"))[0].map(row => [ + row.Variable_name, + Number(row.Value) + ]) + ) + ) +} + +async function tableCounters(source, view) { + return await view.read(async trx => + Object.fromEntries( + ( + await source + .toDb(trx) + .raw( + 'SELECT OBJECT_NAME, COUNT_FETCH FROM performance_schema.table_io_waits_summary_by_table WHERE OBJECT_SCHEMA=DATABASE() ORDER BY OBJECT_NAME' + ) + )[0].map(row => [row.OBJECT_NAME, Number(row.COUNT_FETCH)]) + ) + ) +} + +async function observePage(source, view, read, leftId) { + const queries = [] + const listener = q => { + if (q.sql.startsWith('select') && q.sql.includes('snapshot_certificate_field_keys') && q.sql.includes('cross join')) + queries.push(q) + } + const tablesBefore = await tableCounters(source, view) + const before = await handlerCounters(source, view) + source.knex.on('query', listener) + let page + try { + page = await read( + 'certificateFields', + leftId === undefined + ? undefined + : { + version: 1, + snapshotId: 'large-seek-fixture', + table: 'certificateFields', + after: [String(leftId).padStart(6, '0'), 1] + }, + { maxRows: 16, maxBytes: 131072 } + ) + } finally { + source.knex.off('query', listener) + } + const after = await handlerCounters(source, view) + const tablesAfter = await tableCounters(source, view) + const fetches = Object.fromEntries( + Object.keys(tablesAfter).map(name => [name, tablesAfter[name] - (tablesBefore[name] ?? 0)]) + ) + const deltas = Object.fromEntries(Object.keys(after).map(key => [key, after[key] - before[key]])) + const expectedIds = Array.from( + { length: Math.min(16, (8190 - (leftId ?? -2)) / 2) }, + (_, i) => (leftId ?? -2) + (i + 1) * 2 + ) + assert.deepEqual( + page.rows.map(row => Number(row.fieldName)), + expectedIds + ) + const plans = [] + await runInSeries(queries, async q => { + plans.push(await view.read(async trx => (await source.toDb(trx).raw('EXPLAIN ' + q.sql, q.bindings))[0])) + }) + // Session Handler counters can include lazy work outside the wallet tables. + // Enforce native fetch bounds on this isolated database, including both page + // passes and ownership guards; retain session counters as diagnostic evidence. + assert.equal(fetches.certificate_fields, page.rows.length * 2) + assert.equal(fetches.certificates, page.rows.length) + assert.equal(fetches.users, 1) + assert.ok( + fetches.snapshot_certificate_field_keys <= page.rows.length * 2 + 4, + 'Both passes must seek bounded auxiliary keys: ' + JSON.stringify(fetches) + ) + assert.ok(fetches.snapshot_certificate_field_keys >= page.rows.length * 2) + const allowed = new Set(['certificate_fields', 'certificates', 'users', 'snapshot_certificate_field_keys']) + assert.ok( + Object.entries(fetches).every(([table, count]) => allowed.has(table) || count === 0), + 'A certificate page must not fetch unrelated wallet tables' + ) + assert.ok(deltas.Handler_read_rnd_next <= 64, 'The page must not scan rows into a temporary table') + assert.ok( + plans.every( + plan => + plan[0].table === 'snapshot_certificate_field_keys' && + plan[0].key === 'PRIMARY' && + (leftId === undefined ? ['ref', 'range'].includes(plan[0].type) : plan[0].type === 'range') + ), + 'Both page passes must use the profile prefix or cursor range' + ) + return { after: leftId ?? null, rows: page.rows.length, deltas, fetches, plans, queries } +} + +async function largeSeeks(source, userId, otherId) { + await seedLargeFields(source.knex, userId, otherId) + const results = [] + await runInSeries(['fresh', 'refreshed'], async statistics => { + if (statistics === 'refreshed') + await source.knex.raw('ANALYZE TABLE snapshot_certificate_field_keys, certificate_fields') + const view = await source.openReadSnapshot() + const read = pageReader(source, userId, 'large-seek-fixture', view, true, true, true) + try { + await runInSeries([undefined, 1500, 7000, 8180], async leftId => + results.push({ statistics, ...(await observePage(source, view, read, leftId)) }) + ) + } finally { + await view.close() + } + }) + return results +} + +async function qualifyCollation(control, connection, collation) { + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'ts569_snapshot') + const database = 'ts569_certificate_seeks_' + randomUUID().replaceAll('-', '') + await control.raw(`CREATE DATABASE ?? CHARACTER SET utf8mb4 COLLATE ${collation}`, [database]) + const source = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + }) + try { + await source.migrate('synthetic certificate seek', 'synthetic-certificate-seek') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } + await runInSeries( + [ + [1, user.userId], + [2, other.userId] + ], + async ([id, userId]) => { + await source.knex('certificates').insert({ + ...dates, + certificateId: id, + userId, + serialNumber: 'certificate-' + id, + type: 'type', + certifier: '02' + '11'.repeat(32), + subject: '02' + '11'.repeat(32), + revocationOutpoint: 'a'.repeat(64) + '.0', + signature: 'synthetic', + isDeleted: false + }) + } + ) + return { collation, pages: await largeSeeks(source, user.userId, other.userId) } + } finally { + await source.destroy() + await control.raw('DROP DATABASE ??', [database]) + } +} +async function qualifyMysqlCertificateIndexSeeks(control, connection) { + const results = [] + await runInSeries(['utf8mb4_0900_ai_ci', 'utf8mb4_unicode_ci', 'utf8mb4_bin'], async collation => + results.push(await qualifyCollation(control, connection, collation)) + ) + return results +} +module.exports = { qualifyMysqlCertificateIndexSeeks } diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotCertificateFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotCertificateFixtures.ts new file mode 100644 index 000000000..fa03e9222 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotCertificateFixtures.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { knex, type Knex } from 'knex' +import { runInSeries } from '../../src/utility/runInSeries' + +// Independently defined source graph and SQL union oracle; no migration registry +// or auxiliary trigger expression is used to derive expected membership. +export const certificateFieldNames = ['', 'A', 'a', 'é', 'e\u0301', 'Ω', '名字', '🚲', 'a ', 'A\0B', '😀'.repeat(100)] +export interface CertificateOperation { + kind: number + id: number + user: number + name: number +} + +export async function createCertificateSource(k: Knex, collation = 'BINARY'): Promise { + const mysql = String(k.client.config.client).includes('mysql') + assert.ok(['BINARY', 'NOCASE', 'RTRIM'].includes(collation)) + await k.raw( + mysql + ? 'CREATE TABLE certificates(certificateId INT UNSIGNED PRIMARY KEY,userId INT UNSIGNED NOT NULL,isDeleted TINYINT NOT NULL DEFAULT 0) ENGINE=InnoDB' + : 'CREATE TABLE certificates(certificateId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,isDeleted INTEGER NOT NULL DEFAULT 0)' + ) + await k.raw( + mysql + ? 'CREATE TABLE certificate_fields(userId INT UNSIGNED NOT NULL,fieldName VARCHAR(100) NOT NULL,certificateId INT UNSIGNED NOT NULL,fieldValue VARCHAR(255) NOT NULL,UNIQUE(fieldName,certificateId)) ENGINE=InnoDB' + : `CREATE TABLE certificate_fields(userId INTEGER NOT NULL,fieldName VARCHAR(100) COLLATE ${collation} NOT NULL,certificateId INTEGER NOT NULL,fieldValue VARCHAR(255) NOT NULL,UNIQUE(fieldName,certificateId))` + ) +} + +export async function minimalCertificateDatabase(collation = 'BINARY'): Promise { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + try { + await createCertificateSource(k, collation) + return k + } catch (error) { + await k.destroy() + throw error + } +} + +export async function expectCertificateMembership(k: Knex): Promise { + const result = await k.raw( + 'SELECT userId,fieldName,certificateId,SUM(bit) AS membership FROM (SELECT userId,fieldName,certificateId,1 AS bit FROM certificate_fields UNION ALL SELECT c.userId,f.fieldName,f.certificateId,2 AS bit FROM certificate_fields f JOIN certificates c ON c.certificateId=f.certificateId) owned_fields GROUP BY userId,fieldName,certificateId ORDER BY userId,fieldName,certificateId' + ) + const rows: Array<{ userId: number; fieldName: string; certificateId: number; membership: number | string }> = String( + k.client.config.client + ).includes('mysql') + ? result[0] + : result + const expected = rows.map(row => ({ ...row, membership: Number(row.membership) })) + const actual = await k('snapshot_certificate_field_keys') + .select({ + userId: 'snapshotUserId', + fieldName: 'snapshotFieldName', + certificateId: 'snapshotCertificateId', + membership: 'snapshotMembership' + }) + .orderBy(['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId']) + expect(actual).toEqual(expected) +} + +export async function seedCertificateFields(k: Knex): Promise { + await k('certificate_fields').delete() + await k('certificates').delete() + await k('certificates').insert([ + { certificateId: 1, userId: 1 }, + { certificateId: 2, userId: 2 } + ]) + await runInSeries(certificateFieldNames.entries(), async ([i, fieldName]) => { + await k('certificate_fields') + .insert({ userId: (i % 2) + 1, fieldName, certificateId: (i % 2) + 1, fieldValue: 'initial' }) + .onConflict(['fieldName', 'certificateId']) + .merge(['userId', 'fieldValue']) + }) +} + +export async function applyCertificateOperation(k: Knex, op: CertificateOperation): Promise { + const fieldName = certificateFieldNames[op.name], + certificateId = op.id + const selected = () => k('certificate_fields').where({ fieldName, certificateId }) + switch (op.kind) { + case 0: + await k('certificates').insert({ certificateId, userId: op.user }).onConflict('certificateId').merge(['userId']) + break + case 1: + await k('certificates').where({ certificateId }).delete() + break + case 2: + await k('certificate_fields') + .insert({ userId: op.user, fieldName, certificateId, fieldValue: 'value-' + op.user }) + .onConflict(['fieldName', 'certificateId']) + .merge(['userId', 'fieldValue']) + break + case 3: + await selected().delete() + break + case 4: + await selected().update({ userId: op.user }) + break + case 5: + if ( + !(await k('certificates') + .where({ certificateId: certificateId + 4 }) + .first()) + ) + await k('certificates') + .where({ certificateId }) + .update({ certificateId: certificateId + 4 }) + break + case 6: + try { + await selected().update({ fieldName: certificateFieldNames[(op.name + 1) % certificateFieldNames.length] }) + } catch (error) { + if (!['ER_DUP_ENTRY', 'SQLITE_CONSTRAINT_UNIQUE'].includes((error as { code: string }).code)) throw error + } + break + case 7: + await assert.rejects( + k.transaction(async trx => { + await trx('certificate_fields').where({ fieldName, certificateId }).delete() + throw new Error('synthetic operation rollback') + }), + /synthetic operation rollback/ + ) + break + case 8: + await k('certificates') + .where({ certificateId }) + .update({ isDeleted: k.raw('1-isDeleted') }) + break + default: + throw new Error('Unknown certificate fixture operation') + } +} diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index d78982418..7c665c68f 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -20,6 +20,7 @@ const plans = new Map([ ['src/storage/snapshot/KnexWalletReadSnapshot.ts', 'reader'], ['src/storage/schema/snapshotProfileIndexMigration.ts', 'profile-index'], ['src/storage/schema/snapshotRelationIndexMigration.ts', 'relation-index'], + ['src/storage/schema/snapshotCertificateIndexMigration.ts', 'certificate-index'], ['src/storage/StorageKnex.ts', 'storage'], ['src/storage/StorageProvider.ts', 'storage'] ]) diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index e9488ed7c..97bbf1513 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -260,7 +260,7 @@ test('HTTP execution preserves every canonical range, full configuration and fut for (const [id, expected, fallback] of [ [ 'wallet-retained-snapshot', - ['lifecycle', 'reader', 'profile-index', 'relation-index', 'storage'], + ['lifecycle', 'reader', 'profile-index', 'relation-index', 'certificate-index', 'storage'], 'lifecycle' ], ['wallet-snapshot-archive', ['store', 'capture', 'source'], 'capture'], diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 040e6e8fa..f08fa84b9 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -200,7 +200,8 @@ test('additional package-relative fixture inputs select their target without rep const canonical = buildMutationTargets(REPOSITORY_ROOT) assert.equal(Object.keys(canonical).length, 46) assert.deepEqual(canonical['wallet-retained-snapshot'].additionalInputs, [ - 'test/utils/snapshotRelationFixtures.ts' + 'test/utils/snapshotRelationFixtures.ts', + 'test/utils/snapshotCertificateFixtures.ts' ]) assert.deepEqual( selectAffectedMutationTargets(canonical, [ @@ -209,6 +210,8 @@ test('additional package-relative fixture inputs select their target without rep ['wallet-retained-snapshot'] ) for (const input of [ + 'src/storage/schema/snapshotCertificateIndexMigration.ts', + 'test/utils/snapshotCertificateFixtures.ts', 'src/storage/schema/snapshotRelationIndexMigration.ts', 'src/storage/schema/snapshotProfileIndexMigration.ts', 'src/storage/snapshot/RetainedReadSnapshot.property.test.ts' diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index e5957cdaa..eaae50d5b 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -14,8 +14,12 @@ Native fixtures cover seven migrator process-loss boundaries, independent writer locks under both MySQL isolation levels and late-page range/read-count evidence both before and after native optimizer statistics refresh. Repository and exact-head qualification must still complete for this checkpoint. -The other three indirect tables, commit ordering, nonblocking IndexedDB and the -remaining program below remain open; this does not complete S2. +The subsequent certificate-field checkpoint preserves text collation and exact +field names while indexing both direct and parent ownership. Its resumable +migration and retained-view adoption include ordinary and archive paths; native +process-loss, concurrent-writer and bounded-page qualification remain mandatory. +The two global proof/request tables, commit ordering, nonblocking IndexedDB and +the remaining program below remain open; this does not complete S2. ## Baseline and immediate defect From c050d62b23b61ab065cb6db81b7628c60362bad0 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 15:08:47 -0700 Subject: [PATCH 077/127] test(wallet): tighten certificate migration validation regressions --- .../SnapshotCertificateIndexes.mysql.test.ts | 50 ++++++++++++++++-- .../SnapshotCertificateIndexes.test.ts | 52 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.mysql.test.ts index b28248c06..9711aba9d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.mysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.mysql.test.ts @@ -35,15 +35,19 @@ function fixture(change: (kind: Kind, rows: Metadata) => unknown = (_kind, rows) let journaled = false const answer = (sql: string, values: unknown[]): unknown => { queries.push({ sql, values }) - if (sql.startsWith('SELECT TABLE_NAME AS name')) + if (sql.startsWith('SELECT TABLE_NAME AS name')) { + expect(values).toEqual(['certificate_fields', 'certificates']) return change('sourceTables', [ { name: 'certificate_fields', engine: 'InnoDB' }, { name: 'certificates', engine: 'InnoDB' } ]) - if (sql.startsWith('SELECT COLUMN_TYPE AS type')) + } + if (sql.startsWith('SELECT COLUMN_TYPE AS type')) { + expect(values).toEqual(['certificate_fields', 'fieldName']) return change('sourceColumn', [ { type: 'varchar(100)', nullable: 'NO', charset: 'utf8mb4', collation: 'utf8mb4_0900_ai_ci' } ]) + } if (sql.startsWith('SELECT ENGINE AS engine')) return change('tables', [{ engine: 'InnoDB' }]) if (sql.startsWith('select * from information_schema.tables')) return tables.has(String(values[0])) ? [{ TABLE_NAME: values[0] }] : [] @@ -225,9 +229,21 @@ test('MySQL owns transactional exact-collation tables, installs observers first, expect(ddl[4]).toContain('snapshot_certificate_field_insert') expect(ddl.some(sql => sql.includes('CAST(OLD.fieldName AS BINARY)'))).toBe(true) expect(ddl.filter(sql => sql.includes('FOR SHARE'))).toHaveLength(4) + expect( + ddl.filter(sql => + sql.includes( + 'ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | ' + ) + ) + ).toHaveLength(4) expect(f.queries.filter(q => q.sql.startsWith('create table')).every(q => q.sql.includes('engine = InnoDB'))).toBe( true ) + expect( + f.queries + .filter(q => q.sql.startsWith('create table')) + .every(q => q.sql.includes('varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci')) + ).toBe(true) expect(f.progress()).toEqual({ snapshotTableId: 0, started: true, @@ -276,9 +292,35 @@ test.each([ ['indexes', null] ] as Array<[Kind, unknown]>)('invalid %s metadata refuses adoption and removal', async (kind, value) => { const f = fixture((current, rows) => (current === kind ? value : rows)) + const message = + kind === 'sourceTables' + ? 'Snapshot certificate source requires transactional tables' + : kind === 'sourceColumn' + ? 'Unsupported snapshot certificate field definition' + : 'Snapshot certificate table definition mismatch' + try { + await expect(install(f.k)).rejects.toThrow(message) + await expect(remove(f.k)).rejects.toThrow(message) + } finally { + await f.k.destroy() + } +}) + +test('MySQL resumes equivalent trigger whitespace without replacing the installed observers', async () => { + let reformatted = false + const f = fixture((kind, rows) => + reformatted && kind === 'triggers' + ? rows.map(row => ({ ...row, body: ' \n' + String(row.body).replaceAll(' ', '\n\t ') + '\n ' })) + : rows + ) try { - await expect(install(f.k)).rejects.toThrow() - await expect(remove(f.k)).rejects.toThrow() + await install(f.k) + const created = f.queries.filter(q => q.sql.startsWith('CREATE TRIGGER')).length + reformatted = true + await install(f.k) + expect(f.queries.filter(q => q.sql.startsWith('CREATE TRIGGER'))).toHaveLength(created) + await remove(f.k) + expect(f.triggers.size).toBe(0) } finally { await f.k.destroy() } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.test.ts index 408116765..e42dedd7b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.test.ts @@ -259,6 +259,58 @@ test('a separately collated unique index cannot redefine the source cursor order expect(await k.schema.hasTable('snapshot_certificate_field_keys')).toBe(false) }) +test.each([ + 'fieldName', + 'certificateId,fieldName', + 'fieldName,certificateId,userId', + 'fieldName DESC,certificateId', + 'fieldName,certificateId DESC', + 'fieldName,certificateId COLLATE NOCASE' +])('an incompatible source unique key %s cannot define the snapshot order', async columns => { + const k = await fixture() + await k.schema.dropTable('certificate_fields') + await k.raw( + 'CREATE TABLE certificate_fields(userId INTEGER,fieldName VARCHAR(100),certificateId INTEGER,fieldValue TEXT)' + ) + await k.raw('CREATE UNIQUE INDEX unsupported_source ON certificate_fields(' + columns + ')') + await expect(install(k)).rejects.toThrow('Unsupported snapshot certificate field order') + expect(await k.schema.hasTable('snapshot_certificate_field_keys')).toBe(false) +}) + +test.each([ + 'renamedMembership INTEGER NOT NULL', + 'snapshotMembership TEXT NOT NULL', + 'snapshotMembership INTEGER', + 'snapshotMembership INTEGER NOT NULL DEFAULT 0', + 'snapshotMembership INTEGER GENERATED ALWAYS AS (1) VIRTUAL NOT NULL' +])('an incompatible owned column %s cannot be resumed, adopted or removed', async membership => { + const k = await fixture() + await install(k) + await journal(k) + await k.schema.dropTable('snapshot_certificate_field_keys') + await k.raw( + 'CREATE TABLE snapshot_certificate_field_keys(snapshotUserId INTEGER NOT NULL,snapshotFieldName VARCHAR(100) NOT NULL,snapshotCertificateId INTEGER NOT NULL,' + + membership + + ',PRIMARY KEY(snapshotUserId,snapshotFieldName,snapshotCertificateId))' + ) + await expect(install(k)).rejects.toThrow('table definition mismatch') + await expect(enabled(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') + expect(await k.schema.hasTable('snapshot_certificate_field_keys')).toBe(true) +}) + +test('resume accepts the full-width field cursor and refuses an unrelated progress row', async () => { + const k = await fixture() + await k('certificate_fields').insert({ userId: 1, certificateId: 1, fieldName: '😀'.repeat(100), fieldValue: 'v' }) + await install(k) + await journal(k) + expect(await enabled(k)).toBe(true) + await install(k) + await expectCertificateMembership(k) + await k('snapshot_certificate_index_progress').update({ snapshotTableId: 1 }) + await expect(enabled(k)).rejects.toThrow('migration is incomplete') +}) + test('bootstrap retains orphan fields as directly owned without inventing parent membership', async () => { const k = await fixture() await k('certificate_fields').insert({ userId: 7, fieldName: 'orphan', certificateId: 99, fieldValue: 'synthetic' }) From 1a1682c1e73c2bfe4ab9cfb39679fb8e08899917 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 16:02:32 -0700 Subject: [PATCH 078/127] feat(wallet): add resumable global snapshot reference indexes --- docs/guides/wallet-sync-reliability.md | 66 +- docs/reference/package-api-migrations.md | 72 +- docs/reference/test-quality-governance.md | 6 +- governance/mutation-testing/targets.mjs | 5 +- governance/package-release-notes.json | 2 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 10 +- packages/wallet/wallet-toolbox/README.md | 8 +- .../src/storage/schema/KnexMigrations.ts | 12 + .../snapshotCertificateIndexMigration.ts | 23 +- .../schema/snapshotGlobalIndexMigration.ts | 635 ++++++++++++++++++ .../schema/snapshotGlobalIndexTriggers.ts | 121 ++++ .../ConcurrentSnapshotArchiveSource.test.ts | 2 +- .../snapshot/KnexWalletReadSnapshot.ts | 165 +++-- .../SnapshotGlobalIndexes.integration.test.ts | 230 +++++++ .../SnapshotGlobalIndexes.mysql.test.ts | 519 ++++++++++++++ .../snapshot/SnapshotGlobalIndexes.test.ts | 364 ++++++++++ .../SnapshotProfileIndexes.migration.test.ts | 4 +- .../KnexSnapshotArchiveCapture.test.ts | 8 +- .../archive/KnexSnapshotArchiveClosure.ts | 6 +- .../archive/KnexSnapshotArchiveSource.ts | 47 +- .../archive/KnexSnapshotArchiveStore.test.ts | 2 +- .../archive/SnapshotArchiveHttp.test.ts | 2 +- .../test/storage/runSnapshotArchiveMysql.cjs | 28 +- .../test/storage/snapshotArchiveCrash.cjs | 2 + .../test/storage/snapshotArchiveMysql.cjs | 97 ++- .../storage/snapshotCertificateIndexMysql.cjs | 19 +- ...snapshotCertificateIndexMysqlSchedules.cjs | 14 +- .../test/storage/snapshotGlobalIndexCrash.cjs | 268 ++++++++ .../storage/snapshotGlobalIndexFixtures.cjs | 171 +++++ .../snapshotGlobalIndexIntegration.cjs | 320 +++++++++ .../test/storage/snapshotGlobalIndexMysql.cjs | 136 ++++ .../snapshotGlobalIndexMysqlSchedules.cjs | 102 +++ .../test/storage/snapshotGlobalIndexSeeks.cjs | 163 +++++ .../storage/snapshotMysqlFixtureGroups.cjs | 13 + .../test/utils/snapshotGlobalFixtures.ts | 145 ++++ scripts/mutation-partitions.mjs | 2 + scripts/mutation-partitions.test.mjs | 11 +- scripts/mutation-testing.test.mjs | 6 +- specs/wallet/sync-portability-program.md | 9 +- 39 files changed, 3616 insertions(+), 199 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexTriggers.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.integration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.mysql.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexCrash.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexFixtures.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexIntegration.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexMysql.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexMysqlSchedules.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexSeeks.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotMysqlFixtureGroups.cjs create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotGlobalFixtures.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index f0dac2bbb..76d47e3ed 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -308,9 +308,10 @@ native MySQL fixture observes independent locks under READ COMMITTED and REPEATABLE READ and checks late-page native row-read counts with interleaved profiles both before and after ANALYZE TABLE. These are isolated synthetic fixtures, not deployed PXC or physical mobile -qualification. Certificate fields, proof requests/proofs, source commit ordering, -nonblocking IndexedDB, streaming and staged restore remain required. Reader -advertisement stays disabled and #569 remains open. +qualification. The following migrations cover certificate fields and global +proof requests/proofs. Source commit ordering, nonblocking IndexedDB, streaming +and staged restore remain required. Reader advertisement stays disabled and +#569 remains open. ### Auxiliary certificate-field indexes (unpublished candidate) @@ -353,8 +354,63 @@ writer-lock checks, malformed metadata refusals and actual migration-process termination at seven DDL/bootstrap boundaries on both databases. Native reader fixtures measure first and late pages before and after optimizer statistics refresh. These are synthetic source qualification fixtures, not deployed or physical mobile -acceptance. Proof/request indexes, commit ordering, nonblocking IndexedDB and -the remaining sync/portability program remain open. +acceptance. The following migration covers proof/request indexes; commit +ordering, nonblocking IndexedDB and the remaining sync/portability program +remain open. + +### Auxiliary global proof/request indexes (unpublished candidate) + +Migration `2026-10-01-006 add snapshot global reference indexes` adds four +auxiliary tables: `snapshot_global_edges`, `snapshot_global_keys`, +`snapshot_global_guards` and `snapshot_global_index_progress`. Requests belong +to profiles with a transaction whose txid matches the request. Proofs belong +through either a transaction's direct proof reference or a matching request's +proof reference. Multiple transactions and both reference bases retain separate +edges; removing one basis cannot remove another profile's remaining reference. +Missing proofs keep bounded presence metadata so later insertion or deletion +updates the same indexed selection. Unused proof guards are collected after the +last reference disappears. + +Triggers serialize reference counts and proof presence in the writer's atomic +transaction. MySQL uses current locking reads, including an auxiliary lock for an +absent proof, so an older REPEATABLE READ snapshot cannot publish stale presence +or restore an obsolete request pointer. Removal observers precede producers. +Bootstrap locks at most 256 current transaction rows and commits their edges and +progress together. Retrying a committed batch does not increment reference counts +twice. Source txid comparison metadata must agree; source indexes, exact text, +standard rows, legacy OFFSET and portable/cursor encodings remain unchanged. + +MySQL requires the standard unsigned key and matching text definitions on +transactional InnoDB tables. Custom CASCADE or SET NULL foreign-key actions are +refused before auxiliary DDL because InnoDB does not invoke affected child row +triggers for implicit cascades. Standard RESTRICT and NO ACTION definitions are +supported. SQLite verifies standard key/column definitions and complete txid +lookup indexes with matching BINARY, NOCASE or RTRIM order. Invalid legacy keys +or oversized text refuse bootstrap without publishing completion. + +This is another explicit `transaction: false` migration. Preserve partial owned +DDL and committed positions, exclude other migrators, and recover a stale Knex +lock only after proving its migrator stopped. Ordinary and archive readers adopt +complete journal/progress state inside their pinned view; absent journals keep +legacy selection and incomplete journaled state refuses opening. MySQL pages +use the auxiliary `(table, user, present, row)` index and indexed source lookups, +including before statistics refresh. Drain readers before down; validate all +owned objects before stopping producers and removing auxiliary tables. These +auxiliary structures remain outside BRC-38. + +Fixtures cover shared references, full-width unsigned IDs, source collations, +independent writer commits, retained ordinary/archive pages, bootstrap replay, +eight migrator process-loss boundaries, and first/late query plans and measured +row fetches. The pinned native launcher runs every archive/profile/relation/ +certificate/global family sequentially with a 60-second child deadline per +group and verified owned-container cleanup. The global family is split into +complete process-loss, locking, schedule, seek and integration groups after its +combined local run consumed 51.6 seconds of the 60-second allowance. Every earlier +case remains mandatory. Complete exact-head qualification remains mandatory; +these synthetic fixtures do not establish deployed PXC or physical mobile +acceptance. Commit ordering, tombstones, primary reconciliation, nonblocking +IndexedDB, remote destinations, streaming/staged portability and full system +acceptance remain open. Reader advertisement stays disabled. ## Durable local SQL sync and ordinary backup diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index e96773af6..6eac9ab2a 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -515,7 +515,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) - Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. +- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 57aa8f2b0..fd43af3b4 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -233,7 +233,11 @@ reader and service guard execution. Retained profile and relation migrations now each execute as a whole-file part, alongside reader, storage and the lifecycle fallback. The subsequent certificate-field migration is registered as another complete source and whole-file part with the same full retained test selection; -its independently defined fixture is included in the input digest. Archive groups store/migration, source/closure and the capture fallback. +its independently defined fixture is included in the input digest. Global +proof/request migration and trigger sources are each registered in full and run +in disjoint whole-file parts, with the independent global fixture included in +the same retained input digest. These parts preserve the complete canonical +source and test union. Archive groups store/migration, source/closure and the capture fallback. Remote reader groups lease, rows, page/open/cursor and the admission fallback. These partitions retain every original source specification and full test configuration. The single-file retained reader remains one complete part; diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 8c3e5a32c..eb90efed9 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -400,7 +400,8 @@ export function buildMutationTargets(repositoryRoot) { manifest: 'packages/wallet/wallet-toolbox/package.json', additionalInputs: [ 'test/utils/snapshotRelationFixtures.ts', - 'test/utils/snapshotCertificateFixtures.ts' + 'test/utils/snapshotCertificateFixtures.ts', + 'test/utils/snapshotGlobalFixtures.ts' ], propertyTest: 'packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts', @@ -410,6 +411,8 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/schema/snapshotProfileIndexMigration.ts', 'src/storage/schema/snapshotRelationIndexMigration.ts', 'src/storage/schema/snapshotCertificateIndexMigration.ts', + 'src/storage/schema/snapshotGlobalIndexMigration.ts', + 'src/storage/schema/snapshotGlobalIndexTriggers.ts', sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index ed11a546d..ef36c772e 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -211,7 +211,7 @@ "publishedVersion": "2.14.4", "releaseType": "minor", "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete." + "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index e75b5031b..a3c2e692b 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,11 +6,19 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add resumable global proof/request ownership indexes with exact reference + counts and proof-presence guards. Preserve both ownership bases, current + independent-writer changes and pinned ordinary/archive views. Bootstrap, + process-loss recovery and native first/late page qualification keep standard + rows/indexes and portable/cursor bytes unchanged. Complete system acceptance + and the remaining sync/portability program are still required. + - Add resumable certificate-field profile indexes preserving source collation, empty names, direct/parent ownership and exact rename bytes. Ordinary and archive readers adopt complete migration state inside their retained view; standard indexes, legacy OFFSET order and BRC-38/cursor bytes stay unchanged. - Proof/request indexing and the full program remain incomplete. + The subsequent global checkpoint extends this selection to proofs/requests; + the full program remains incomplete. - Add auxiliary numeric relationship indexes for label/tag maps without changing standard indexes, composite cursors, legacy OFFSET order or BRC-38 bytes. diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 1b59a820d..87faf9b96 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -120,6 +120,12 @@ independent direct/parent ownership. Empty text keys, interrupted bootstrap and case-only renames retain their source meaning. It leaves standard indexes and legacy cursor order intact; readers require the complete migration in their pinned view. See the [certificate migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-certificate-field-indexes-unpublished-candidate). +The global proof/request migration adds exact profile reference counts and +presence guards, with resumable bootstrap and retained ordinary/archive reads. +Direct and request-based proof references remain independent; removing the last +reference collects its auxiliary presence guard. Standard rows, text comparisons, +indexes and cursor/portable bytes remain unchanged. See the +[global migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-global-proofrequest-indexes-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results @@ -147,7 +153,7 @@ Timing compares successive candidates, not a controlled comparison against upstr SQLite migration handling introduced in 2.13.2 runs transactional migration DDL and the migration journal update together. The unpublished profile-index and -numeric relation and certificate-field migrations are explicit resumable exceptions: auxiliary keys and progress commit +numeric relation, certificate-field and global proof/request migrations are explicit resumable exceptions: auxiliary keys and progress commit in bounded batches before its final migration journal entry. Foreign-key enforcement is disabled before the migration transaction for table rebuilds and restored after success or failure. Failed transactional migrations can be retried after reopening the database diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index 8f3166f0f..07f48a6c6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,8 @@ +import { + addSnapshotGlobalIndexes, + removeSnapshotGlobalIndexes, + SNAPSHOT_GLOBAL_INDEX_MIGRATION +} from './snapshotGlobalIndexMigration' import { addSnapshotCertificateIndexes, removeSnapshotCertificateIndexes, @@ -47,6 +52,7 @@ import { LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS } from '../methods/managedChangePolicy' +export { SNAPSHOT_GLOBAL_INDEX_MIGRATION } from './snapshotGlobalIndexMigration' export { SNAPSHOT_CERTIFICATE_INDEX_MIGRATION } from './snapshotCertificateIndexMigration' export { SNAPSHOT_RELATION_INDEX_MIGRATION } from './snapshotRelationIndexMigration' export { SNAPSHOT_PROFILE_INDEX_MIGRATION } from './snapshotProfileIndexMigration' @@ -140,6 +146,12 @@ export class KnexMigrations implements MigrationSource { // DDL may commit independently on MySQL. Bootstrap pages retain their own // durable positions on both backends and resume before journal publication. + migrations[SNAPSHOT_GLOBAL_INDEX_MIGRATION] = { + config: { transaction: false }, + up: addSnapshotGlobalIndexes, + down: removeSnapshotGlobalIndexes + } + migrations[SNAPSHOT_CERTIFICATE_INDEX_MIGRATION] = { config: { transaction: false }, up: addSnapshotCertificateIndexes, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts index ca0d319aa..c6314ec85 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts @@ -62,8 +62,8 @@ async function sourceText(k: Knex): Promise { columns.length !== 1 || column?.type !== 'varchar(100)' || column.nullable !== 'NO' || - !/^[a-zA-Z0-9_]+$/.test(column.charset) || - !/^[a-zA-Z0-9_]+$/.test(column.collation) + !/^\w+$/.test(column.charset) || + !/^\w+$/.test(column.collation) ) throw new WERR_INVALID_OPERATION('Unsupported snapshot certificate field definition') return { charset: column.charset, collation: column.collation } @@ -102,7 +102,8 @@ async function sourceText(k: Knex): Promise { } function fieldType(text: TextDefinition): string { - return `varchar(100)${text.charset === null ? '' : ` CHARACTER SET ${text.charset}`} COLLATE ${text.collation}` + const charset = text.charset === null ? '' : ' CHARACTER SET ' + text.charset + return `varchar(100)${charset} COLLATE ${text.collation}` } function insertMembership(isMysql: boolean, select: string, bit: number): string { const merge = isMysql @@ -340,7 +341,8 @@ async function mysqlTable( columns.some((column, i) => { const field = expected[i] const integerType = field.unsigned === true ? 'int unsigned' : 'int' - const type = field.type === 'varchar(100)' ? field.type : field.type === 'boolean' ? 'tinyint' : integerType + const scalarType = field.type === 'boolean' ? 'tinyint' : integerType + const type = field.type === 'varchar(100)' ? field.type : scalarType return ( column.name !== field.name || (textColumn(column.name) ? column.type : column.type.replaceAll(/\(\d+\)/g, '')) !== type || @@ -454,6 +456,12 @@ function positive(value: number | undefined): number { throw new WERR_INVALID_OPERATION('Invalid snapshot certificate source key') return value } +function validateSourceRow(row: { userId: number; fieldName: string; certificateId: number }): void { + positive(row.userId) + positive(row.certificateId) + if (typeof row.fieldName !== 'string' || Array.from(row.fieldName).length > 100) + throw new WERR_INVALID_OPERATION('Invalid snapshot certificate source key') +} async function bootstrapPage(k: Knex): Promise { return await k.transaction(async trx => { if (!mysql(k)) @@ -484,12 +492,7 @@ async function bootstrapPage(k: Knex): Promise { fieldName: string certificateId: number }> = await source - for (const row of rows) { - positive(row.userId) - positive(row.certificateId) - if (typeof row.fieldName !== 'string' || Array.from(row.fieldName).length > 100) - throw new WERR_INVALID_OPERATION('Invalid snapshot certificate source key') - } + rows.forEach(validateSourceRow) if (rows.length !== 0) { const parentQuery = trx('certificates') .select('certificateId', 'userId') diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts new file mode 100644 index 000000000..4f0a0f53b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts @@ -0,0 +1,635 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { runInSeries } from '../../utility/runInSeries' +import { snapshotGlobalIndexTriggers, type SnapshotGlobalIndexTrigger } from './snapshotGlobalIndexTriggers' + +export const SNAPSHOT_GLOBAL_INDEX_MIGRATION = '2026-10-01-006 add snapshot global reference indexes' +const EDGES = 'snapshot_global_edges' +const KEYS = 'snapshot_global_keys' +const GUARDS = 'snapshot_global_guards' +const PROGRESS = 'snapshot_global_index_progress' +const PAGE_ROWS = 256 + +type ColumnType = 'int' | 'uint' | 'biguint' | 'boolean' +interface Column { + name: string + type: ColumnType +} +interface Index { + name: string + columns: string[] +} +interface Table { + name: string + columns: Column[] + primary: string[] + indexes: Index[] +} +const columns = (names: string[], type: ColumnType): Column[] => names.map(name => ({ name, type })) +const tables: Table[] = [ + { + name: GUARDS, + columns: [...columns(['proofId'], 'uint'), ...columns(['present'], 'boolean')], + primary: ['proofId'], + indexes: [] + }, + { + name: KEYS, + columns: [ + ...columns(['tableId'], 'int'), + ...columns(['userId', 'rowId'], 'uint'), + ...columns(['refs'], 'biguint'), + ...columns(['present'], 'boolean') + ], + primary: ['tableId', 'userId', 'rowId'], + indexes: [ + { + name: 'snapshot_global_page', + columns: ['tableId', 'userId', 'present', 'rowId'] + }, + { + name: 'snapshot_global_target', + columns: ['tableId', 'rowId', 'userId'] + } + ] + }, + { + name: EDGES, + columns: [ + ...columns(['transactionId', 'requestId'], 'uint'), + ...columns(['tableId'], 'int'), + ...columns(['rowId', 'userId'], 'uint') + ], + primary: ['transactionId', 'requestId', 'tableId', 'rowId'], + indexes: [ + { + name: 'snapshot_global_request', + columns: ['requestId', 'transactionId'] + } + ] + }, + { + name: PROGRESS, + columns: [...columns(['id'], 'int'), ...columns(['afterRowId'], 'uint'), ...columns(['complete'], 'boolean')], + primary: ['id'], + indexes: [] + } +] +const mysql = (k: Knex): boolean => String(k.client.config.client).includes('mysql') +const normalized = (sql: string): string => sql.replaceAll(/\s+/g, ' ').trim() +function invalid(message: string): never { + throw new WERR_INVALID_OPERATION(message) +} +interface MysqlPart { + name: string + columnName: string + nonUnique: number + direction: string + prefix: unknown +} +async function mysqlParts(k: Knex, table: string): Promise { + const [parts]: MysqlPart[][] = await k.raw( + 'SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX', + [table] + ) + if (!Array.isArray(parts)) invalid('Invalid snapshot global index metadata') + return parts +} +function matchesMysqlIndex(parts: MysqlPart[], name: string, expected: string[]): boolean { + const found = parts.filter(part => part.name === name) + return ( + found.length === expected.length && + found.every((part, i) => part.columnName === expected[i] && part.direction === 'A' && part.prefix === null) + ) +} +async function mysqlTable(k: Knex, table: Table, secondary: boolean): Promise { + const [engines]: Array> = await k.raw( + 'SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [table.name] + ) + if (engines.length !== 1 || engines[0]?.engine !== 'InnoDB') return false + const [actual]: Array< + Array<{ + name: string + type: string + nullable: string + defaultValue: unknown + extra: string + }> + > = await k.raw( + 'SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION', + [table.name] + ) + const types = { + int: 'int', + uint: 'int unsigned', + biguint: 'bigint unsigned', + boolean: 'tinyint' + } + if ( + !Array.isArray(actual) || + actual.length !== table.columns.length || + actual.some( + (column, i) => + column.name !== table.columns[i].name || + column.type.replaceAll(/\(\d+\)/g, '') !== types[table.columns[i].type] || + column.nullable !== 'NO' || + column.defaultValue !== null || + column.extra !== '' + ) + ) + return false + const parts = await mysqlParts(k, table.name) + if (parts.some(part => part.name !== 'PRIMARY' && Number(part.nonUnique) !== 1)) return false + if (!matchesMysqlIndex(parts, 'PRIMARY', table.primary)) return false + return !secondary || table.indexes.every(index => matchesMysqlIndex(parts, index.name, index.columns)) +} +interface SqlitePart { + name: string + desc: number + coll: string + key: number +} +async function sqliteParts(k: Knex, name: string): Promise { + const rows: SqlitePart[] = await k.raw('PRAGMA index_xinfo(??)', [name]) + return rows.filter(row => row.key === 1) +} +async function sqliteTable(k: Knex, table: Table, secondary: boolean): Promise { + const actual: Array<{ + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + hidden: number + }> = await k.raw('PRAGMA table_xinfo(??)', [table.name]) + const types = { + int: 'integer', + uint: 'integer', + biguint: 'bigint', + boolean: 'boolean' + } + if ( + !Array.isArray(actual) || + actual.length !== table.columns.length || + actual.some( + (column, i) => + column.name !== table.columns[i].name || + column.type.toLowerCase() !== types[table.columns[i].type] || + column.notnull !== 1 || + column.dflt_value !== null || + column.pk !== table.primary.indexOf(column.name) + 1 || + column.hidden !== 0 + ) + ) + return false + const indexes: Array<{ + name: string + unique: number + origin: string + partial: number + }> = await k.raw('PRAGMA index_list(??)', [table.name]) + if (indexes.some(index => index.unique !== 0 && (index.origin !== 'pk' || index.partial !== 0))) return false + const required: Index[] = secondary ? [...table.indexes] : [] + if (table.primary.length > 1) { + const primary = indexes.find(index => index.origin === 'pk') + if (primary === undefined) return false + required.push({ name: primary.name, columns: table.primary }) + } + for (const index of required) { + const found = indexes.find(value => value.name === index.name) + if (found === undefined || found.partial !== 0) return false + const parts = await sqliteParts(k, found.name) + if ( + parts.length !== index.columns.length || + parts.some((part, i) => part.name !== index.columns[i] || part.desc !== 0 || part.coll !== 'BINARY') + ) + return false + } + return true +} +async function validateTable(k: Knex, table: Table, secondary = true): Promise { + if (!(mysql(k) ? await mysqlTable(k, table, secondary) : await sqliteTable(k, table, secondary))) + invalid('Snapshot global table definition mismatch') +} +async function ensureTable(k: Knex, table: Table): Promise { + if (!(await k.schema.hasTable(table.name))) + await k.schema.createTable(table.name, builder => { + if (mysql(k)) void builder.engine('InnoDB') + for (const column of table.columns) { + let field: Knex.ColumnBuilder + if (column.type === 'boolean') field = builder.boolean(column.name) + else if (column.type === 'biguint') field = builder.bigInteger(column.name).unsigned() + else if (column.type === 'uint') field = builder.integer(column.name).unsigned() + else field = builder.integer(column.name) + void field.notNullable() + } + void builder.primary(table.primary) + }) + await validateTable(k, table, false) + await runInSeries(table.indexes, async index => { + const exists = mysql(k) + ? (await mysqlParts(k, table.name)).some(part => part.name === index.name) + : (await k('sqlite_master').where({ type: 'index', name: index.name }).first('name')) !== undefined + if (!exists) + await k.schema.alterTable(table.name, builder => { + void builder.index(index.columns, index.name) + }) + }) + await validateTable(k, table) +} + +const sources = [ + { + name: 'proven_txs', + key: 'provenTxId', + fields: [{ name: 'provenTxId', nullable: false, text: false }] + }, + { + name: 'proven_tx_reqs', + key: 'provenTxReqId', + fields: [ + { name: 'provenTxReqId', nullable: false, text: false }, + { name: 'provenTxId', nullable: true, text: false }, + { name: 'txid', nullable: false, text: true } + ] + }, + { + name: 'transactions', + key: 'transactionId', + fields: [ + { name: 'transactionId', nullable: false, text: false }, + { name: 'userId', nullable: false, text: false }, + { name: 'provenTxId', nullable: true, text: false }, + { name: 'txid', nullable: true, text: true } + ] + } +] +interface MysqlSourceColumn { + name: string + type: string + nullable: string + extra: string + charset: string | null + collation: string | null +} +type SourceDefinition = (typeof sources)[number] +type SourceField = SourceDefinition['fields'][number] +interface SourceText { + charset: string + collation: string +} +function mysqlSourceColumn(column: MysqlSourceColumn | undefined, field: SourceField, key: string): MysqlSourceColumn { + const type = field.text ? 'varchar(64)' : 'int unsigned' + if ( + column === undefined || + (field.text ? column.type : column.type.replaceAll(/\(\d+\)/g, '')) !== type || + column.nullable !== (field.nullable ? 'YES' : 'NO') || + (column.extra !== '' && !(field.name === key && column.extra === 'auto_increment')) + ) + invalid('Unsupported snapshot global source column') + return column +} +function mysqlSourceText(previous: SourceText | undefined, column: MysqlSourceColumn): SourceText { + if (column.charset === null || column.collation === null) invalid('Unsupported snapshot global source text') + if (previous !== undefined && (previous.charset !== column.charset || previous.collation !== column.collation)) + invalid('Snapshot global source comparisons require matching text definitions') + return { charset: column.charset, collation: column.collation } +} +function validateMysqlSourceIndexes(parts: MysqlPart[], source: SourceDefinition): void { + if (!matchesMysqlIndex(parts, 'PRIMARY', [source.key])) invalid('Unsupported snapshot global source key') + if (source.name === 'proven_txs') return + const candidates = [...new Set(parts.map(part => part.name))] + const indexed = candidates.some(name => { + const index = parts.filter(part => part.name === name) + return ( + index[0]?.columnName === 'txid' && + index[0].direction === 'A' && + index[0].prefix === null && + (source.name === 'transactions' || (index.length === 1 && Number(index[0].nonUnique) === 0)) + ) + }) + if (!indexed) invalid('Snapshot global source requires complete transaction lookup indexes') +} +async function validateMysqlSource(k: Knex): Promise { + let text: SourceText | undefined + await runInSeries(sources, async source => { + const [engines]: Array> = await k.raw( + 'SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [source.name] + ) + if (engines.length !== 1 || engines[0]?.engine !== 'InnoDB') + invalid('Snapshot global source requires transactional tables') + const [rules]: Array> = await k.raw( + 'SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?', + [source.name] + ) + // InnoDB cascades do not run the affected child's row triggers. Keep the + // standard RESTRICT/NO ACTION definitions; do not adopt a stale edge index. + const explicit = (rule: string): boolean => rule === 'RESTRICT' || rule === 'NO ACTION' + if (!Array.isArray(rules) || rules.some(rule => !explicit(rule.updateRule) || !explicit(rule.deleteRule))) + invalid('Snapshot global source requires explicit row mutations') + const [actual]: MysqlSourceColumn[][] = await k.raw( + 'SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [source.name] + ) + for (const field of source.fields) { + const column = mysqlSourceColumn( + actual.find(value => value.name === field.name), + field, + source.key + ) + if (field.text) text = mysqlSourceText(text, column) + } + validateMysqlSourceIndexes(await mysqlParts(k, source.name), source) + }) +} +async function sqliteTextOrder(k: Knex, table: string): Promise { + const indexes: Array<{ name: string; unique: number; partial: number }> = await k.raw('PRAGMA index_list(??)', [ + table + ]) + for (const index of indexes) { + if (index.partial !== 0 || (table === 'proven_tx_reqs' && index.unique !== 1)) continue + const parts = await sqliteParts(k, index.name) + if ( + parts[0]?.name !== 'txid' || + parts[0].desc !== 0 || + !['BINARY', 'NOCASE', 'RTRIM'].includes(parts[0].coll) || + (table === 'proven_tx_reqs' && parts.length !== 1) + ) + continue + const plan: Array<{ detail: string }> = await k.raw( + 'EXPLAIN QUERY PLAN SELECT txid FROM ?? INDEXED BY ?? ORDER BY txid LIMIT 1', + [table, index.name] + ) + if (!plan.some(step => step.detail.includes('TEMP B-TREE'))) return parts[0].coll + } + return invalid('Snapshot global source requires complete transaction lookup indexes') +} +async function validateSqliteSource(k: Knex): Promise { + let collation: string | undefined + await runInSeries(sources, async source => { + const actual: Array<{ + name: string + type: string + notnull: number + pk: number + hidden: number + }> = await k.raw('PRAGMA table_xinfo(??)', [source.name]) + const primary = actual.filter(column => column.pk !== 0) + if (primary.length !== 1 || primary[0]?.name !== source.key || primary[0].pk !== 1) + invalid('Unsupported snapshot global source key') + for (const field of source.fields) { + const column = actual.find(value => value.name === field.name) + if ( + column === undefined || + column.type.toLowerCase() !== (field.text ? 'varchar(64)' : 'integer') || + column.hidden !== 0 || + (field.name !== source.key && column.notnull !== (field.nullable ? 0 : 1)) + ) + invalid('Unsupported snapshot global source column') + } + if (source.name !== 'proven_txs') { + const order = await sqliteTextOrder(k, source.name) + if (collation !== undefined && collation !== order) + invalid('Snapshot global source comparisons require matching text definitions') + collation = order + } + }) +} +async function validateSource(k: Knex): Promise { + if (mysql(k)) await validateMysqlSource(k) + else await validateSqliteSource(k) +} +async function validateTrigger(k: Knex, expected: SnapshotGlobalIndexTrigger): Promise { + if (!mysql(k)) { + const row: { sql: string } | undefined = await k('sqlite_master') + .where({ type: 'trigger', name: expected.name }) + .first('sql') + if (row === undefined) return false + if (normalized(row.sql) !== normalized(expected.sql)) invalid('Snapshot global trigger definition mismatch') + return true + } + const [rows]: Array> = await k.raw( + 'SELECT EVENT_MANIPULATION AS event,ACTION_TIMING AS timing,EVENT_OBJECT_TABLE AS tableName,ACTION_STATEMENT AS body FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?', + [expected.name] + ) + if (!Array.isArray(rows)) invalid('Invalid snapshot global trigger metadata') + if (rows.length === 0) return false + const row = rows[0] + if ( + rows.length !== 1 || + row?.event !== expected.event || + row.timing !== expected.timing || + row.tableName !== expected.table || + normalized(row.body) !== normalized(expected.body) + ) + invalid('Snapshot global trigger definition mismatch') + return true +} + +interface Position { + afterRowId: number + complete: boolean | number +} +function validPosition(state: Position | undefined): state is Position { + return ( + state !== undefined && + Number.isSafeInteger(state.afterRowId) && + state.afterRowId >= 0 && + [false, true, 0, 1].includes(state.complete) + ) +} +function positive(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) invalid('Invalid snapshot global source key') + return value +} +interface SourceRow { + transactionId: number + userId: number + provenTxId: number | null + txid: string | null + txidBytes: number +} +interface RequestRow { + provenTxReqId: number + provenTxId: number | null +} +async function currentProof(k: Knex, proofId: number): Promise { + positive(proofId) + await k(GUARDS) + .insert({ proofId, present: false }) + .onConflict('proofId') + .merge({ proofId: k.ref('proofId') }) + // This lock is shared with proof insert/delete triggers, including absence. + // UPDATE reads the current source after acquiring the auxiliary target lock. + if (mysql(k)) + await k.raw( + 'UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=?', + [proofId] + ) + else + await k(GUARDS) + .where('proofId', proofId) + .update({ + present: k.raw('EXISTS(SELECT 1 FROM proven_txs WHERE provenTxId=?)', [proofId]) + }) +} +async function bootstrapRow(k: Knex, row: SourceRow): Promise { + positive(row.transactionId) + positive(row.userId) + if ( + !Number.isSafeInteger(row.txidBytes) || + row.txidBytes < 0 || + row.txidBytes > 256 || + (row.txid !== null && (typeof row.txid !== 'string' || Array.from(row.txid).length > 64)) + ) + invalid('Invalid snapshot global transaction key') + let request: RequestRow | undefined + if (row.txid !== null) { + const query = k('proven_tx_reqs').select('provenTxReqId', 'provenTxId').where('txid', row.txid) + if (mysql(k)) void query.forShare() + request = await query.first() + if (request !== undefined) positive(request.provenTxReqId) + } + const proofs = [ + ...new Set( + [row.provenTxId, request?.provenTxId].filter((value): value is number => value !== null && value !== undefined) + ) + ].sort((a, b) => a - b) + await runInSeries(proofs, async proofId => { + await currentProof(k, proofId) + }) + const values: Array<{ requestId: number; tableId: number; rowId: number }> = [] + if (row.provenTxId !== null) values.push({ requestId: 0, tableId: 1, rowId: positive(row.provenTxId) }) + if (request !== undefined) { + values.push({ + requestId: request.provenTxReqId, + tableId: 0, + rowId: request.provenTxReqId + }) + if (request.provenTxId !== null) + values.push({ + requestId: request.provenTxReqId, + tableId: 1, + rowId: positive(request.provenTxId) + }) + } + if (values.length !== 0) + await k(EDGES) + .insert( + values.map(value => ({ + ...value, + transactionId: row.transactionId, + userId: row.userId + })) + ) + .onConflict(['transactionId', 'requestId', 'tableId', 'rowId']) + .merge({ transactionId: k.ref('transactionId') }) +} +async function bootstrapPage(k: Knex): Promise { + return await k.transaction(async trx => { + if (!mysql(k)) + await trx(PROGRESS) + .where('id', 0) + .update({ complete: trx.ref('complete') }) + const position = trx(PROGRESS).where('id', 0) + if (mysql(k)) void position.forUpdate() + const state: Position | undefined = await position.first() + if (!validPosition(state)) invalid('Invalid snapshot global bootstrap position') + if (state.complete === true || state.complete === 1) return true + if (state.afterRowId === 0) { + // The initial position is below every supported source key. Do not mark a + // malformed legacy SQLite store complete while silently skipping its rows. + const unsupported = trx('transactions').select('transactionId').where('transactionId', '<=', 0) + if (mysql(k)) void unsupported.forUpdate() + if ((await unsupported.first()) !== undefined) invalid('Invalid snapshot global source key') + } + const length = mysql(k) ? 'octet_length(txid)' : 'length(cast(txid AS blob))' + const source = trx('transactions') + .select( + 'transactionId', + 'userId', + 'provenTxId', + trx.raw(`CASE WHEN ${length} <= 256 THEN txid END AS txid`), + trx.raw(`COALESCE(${length},0) AS txidBytes`) + ) + .where('transactionId', '>', state.afterRowId) + .orderBy('transactionId') + .limit(PAGE_ROWS) + if (mysql(k)) void source.forUpdate() + const rows: SourceRow[] = await source + await runInSeries(rows, async row => { + await bootstrapRow(trx, row) + }) + const complete = rows.length < PAGE_ROWS + await trx(PROGRESS) + .where('id', 0) + .update({ + afterRowId: rows.at(-1)?.transactionId ?? state.afterRowId, + complete + }) + return complete + }) +} +export async function addSnapshotGlobalIndexes(k: Knex): Promise { + if (mysql(k) && k.isTransaction) + invalid('Snapshot global migration requires independent DDL and bootstrap transactions') + await validateSource(k) + await runInSeries(tables, async table => { + await ensureTable(k, table) + }) + await runInSeries(snapshotGlobalIndexTriggers(mysql(k)), async trigger => { + if (!(await validateTrigger(k, trigger))) await k.raw(trigger.sql) + }) + await k(PROGRESS).insert({ id: 0, afterRowId: 0, complete: false }).onConflict('id').ignore() + let complete = false + function* unfinishedPages() { + while (!complete) yield undefined + } + await runInSeries(unfinishedPages(), async () => { + complete = await bootstrapPage(k) + }) +} +/** Drain retained readers before removal; all standard source rows/indexes remain. */ +export async function removeSnapshotGlobalIndexes(k: Knex): Promise { + if (mysql(k) && k.isTransaction) + invalid('Snapshot global migration requires independent DDL and bootstrap transactions') + await validateSource(k) + await runInSeries(tables, async table => { + if (await k.schema.hasTable(table.name)) await validateTable(k, table) + }) + const triggers = snapshotGlobalIndexTriggers(mysql(k)).reverse() + await runInSeries(triggers, async trigger => { + await validateTrigger(k, trigger) + }) + await runInSeries(triggers, async trigger => { + await k.raw('DROP TRIGGER IF EXISTS ??', [trigger.name]) + }) + await runInSeries([...tables].reverse(), async table => { + await k.schema.dropTableIfExists(table.name) + }) +} +export async function readSnapshotGlobalIndexState(k: Knex, config?: Knex.MigratorConfig): Promise { + const tableName = config?.tableName ?? 'knex_migrations', + schema = k.schema + if (config?.schemaName !== undefined) void schema.withSchema(config.schemaName) + if (!(await schema.hasTable(tableName))) return false + const journal = k(tableName).where('name', SNAPSHOT_GLOBAL_INDEX_MIGRATION) + if (config?.schemaName !== undefined) void journal.withSchema(config.schemaName) + if ((await journal.first('name')) === undefined) return false + await validateSource(k) + await runInSeries(tables, async table => { + if (!(await k.schema.hasTable(table.name))) invalid('Snapshot global index migration is incomplete') + await validateTable(k, table) + }) + const states: Array = await k(PROGRESS).select('*').limit(2) + if ( + states.length !== 1 || + states[0]?.id !== 0 || + !validPosition(states[0]) || + (states[0].complete !== true && states[0].complete !== 1) + ) + invalid('Snapshot global index migration is incomplete') + return true +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexTriggers.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexTriggers.ts new file mode 100644 index 000000000..17e9818ba --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexTriggers.ts @@ -0,0 +1,121 @@ +/** Migration-owned trigger definitions; source indexes and rows are preserved. */ +export interface SnapshotGlobalIndexTrigger { + name: string + table: string + timing: 'BEFORE' | 'AFTER' + event: 'INSERT' | 'UPDATE' | 'DELETE' + body: string + sql: string +} +const edges = 'snapshot_global_edges', + keys = 'snapshot_global_keys', + guards = 'snapshot_global_guards' +export function snapshotGlobalIndexTriggers(isMysql: boolean): SnapshotGlobalIndexTrigger[] { + const statements: SnapshotGlobalIndexTrigger[] = [] + const trigger = ( + name: string, + table: string, + timing: 'BEFORE' | 'AFTER', + event: 'INSERT' | 'UPDATE' | 'DELETE', + body: string, + when?: string + ): void => { + const condition = isMysql && when !== undefined ? `IF ${when} THEN ` : '' + const end = when !== undefined && isMysql ? ' END IF;' : '' + const fullName = `snapshot_global_${name}` + const fullBody = `BEGIN ${condition}${body}${end} END` + let qualifier = '' + if (isMysql) qualifier = ' FOR EACH ROW' + else if (when !== undefined) qualifier = ' WHEN ' + when + const sql = `CREATE TRIGGER ${fullName} ${timing} ${event} ON ${table}${qualifier} ${fullBody}` + statements.push({ + name: fullName, + table, + timing, + event, + body: fullBody, + sql + }) + } + const edgeInsert = (selection: string): string => + `INSERT INTO ${edges} (transactionId,requestId,tableId,rowId,userId) ${selection}${isMysql ? ' ON DUPLICATE KEY UPDATE transactionId = ' + edges + '.transactionId' : ' ON CONFLICT(transactionId,requestId,tableId,rowId) DO NOTHING'};` + const ensureProof = (expression: string): string => + isMysql + ? `INSERT INTO ${guards} (proofId,present) VALUES (${expression},0) ON DUPLICATE KEY UPDATE proofId=${guards}.proofId; UPDATE ${guards} g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=${expression};` + : `INSERT INTO ${guards} (proofId,present) SELECT ${expression},0 WHERE ${expression} IS NOT NULL ON CONFLICT(proofId) DO NOTHING; UPDATE ${guards} SET present=EXISTS(SELECT 1 FROM proven_txs WHERE provenTxId=${expression}) WHERE proofId=${expression};` + const guardPresence = `CASE WHEN NEW.tableId=0 THEN 1 ELSE (SELECT present FROM ${guards} WHERE proofId=NEW.rowId${isMysql ? ' FOR SHARE' : ''}) END` + const increase = `INSERT INTO ${keys} (tableId,userId,rowId,refs,present) VALUES (NEW.tableId,NEW.userId,NEW.rowId,1,${guardPresence})${isMysql ? ' ON DUPLICATE KEY UPDATE refs=' + keys + '.refs+1' : ' ON CONFLICT(tableId,userId,rowId) DO UPDATE SET refs=refs+1'};` + const countWhere = 'tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId' + const collect = `DELETE FROM ${guards} WHERE proofId=OLD.rowId AND OLD.tableId=1 AND NOT EXISTS(SELECT 1 FROM ${keys} WHERE tableId=1 AND rowId=OLD.rowId);` + trigger( + 'edge_delete', + edges, + 'AFTER', + 'DELETE', + `UPDATE ${keys} SET refs=refs-1 WHERE ${countWhere}; DELETE FROM ${keys} WHERE ${countWhere} AND refs=0; ${collect}` + ) + trigger('edge_insert', edges, 'AFTER', 'INSERT', increase) + const diff = (field: string): string => + isMysql ? `NOT (OLD.${field} <=> NEW.${field})` : `OLD.${field} IS NOT NEW.${field}` + const txnChanged = ['transactionId', 'userId', 'txid', 'provenTxId'].map(diff).join(' OR ') + const reqChanged = ['provenTxReqId', 'txid', 'provenTxId'].map(diff).join(' OR ') + trigger('tx_delete', 'transactions', 'AFTER', 'DELETE', `DELETE FROM ${edges} WHERE transactionId=OLD.transactionId;`) + trigger( + 'tx_before_update', + 'transactions', + 'BEFORE', + 'UPDATE', + `DELETE FROM ${edges} WHERE transactionId=OLD.transactionId;`, + txnChanged + ) + trigger('req_delete', 'proven_tx_reqs', 'AFTER', 'DELETE', `DELETE FROM ${edges} WHERE requestId=OLD.provenTxReqId;`) + trigger( + 'req_before_update', + 'proven_tx_reqs', + 'BEFORE', + 'UPDATE', + `DELETE FROM ${edges} WHERE requestId=OLD.provenTxReqId;`, + reqChanged + ) + const proofPresence = (prefix: 'OLD' | 'NEW', present: 0 | 1): string => { + const merge = isMysql + ? ` ON DUPLICATE KEY UPDATE present=${present}` + : ` ON CONFLICT(proofId) DO UPDATE SET present=${present}` + return `INSERT INTO ${guards} (proofId,present) VALUES (${prefix}.provenTxId,${present})${merge}; UPDATE ${keys} SET present=${present} WHERE tableId=1 AND rowId=${prefix}.provenTxId; DELETE FROM ${guards} WHERE proofId=${prefix}.provenTxId AND NOT EXISTS(SELECT 1 FROM ${keys} WHERE tableId=1 AND rowId=${prefix}.provenTxId);` + } + trigger('proof_delete', 'proven_txs', 'AFTER', 'DELETE', proofPresence('OLD', 0)) + trigger('proof_before_update', 'proven_txs', 'BEFORE', 'UPDATE', proofPresence('OLD', 0), diff('provenTxId')) + trigger('proof_insert', 'proven_txs', 'AFTER', 'INSERT', proofPresence('NEW', 1)) + trigger('proof_after_update', 'proven_txs', 'AFTER', 'UPDATE', proofPresence('NEW', 1), diff('provenTxId')) + let txnBody: string, + declarations = '' + if (isMysql) { + declarations = + 'DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; ' + txnBody = `IF NEW.provenTxId IS NOT NULL THEN ${ensureProof('NEW.provenTxId')} ${edgeInsert('VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId)')} END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN ${edgeInsert('VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId)')} IF requestedProof IS NOT NULL THEN ${ensureProof('requestedProof')} ${edgeInsert('VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId)')} END IF; END IF;` + } else { + const reqProof = '(SELECT provenTxId FROM proven_tx_reqs WHERE txid=NEW.txid)' + txnBody = `${ensureProof('NEW.provenTxId')} ${ensureProof(reqProof)} ${edgeInsert('SELECT NEW.transactionId,0,1,NEW.provenTxId,NEW.userId WHERE NEW.provenTxId IS NOT NULL')} ${edgeInsert('SELECT NEW.transactionId,provenTxReqId,0,provenTxReqId,NEW.userId FROM proven_tx_reqs WHERE txid=NEW.txid')} ${edgeInsert('SELECT NEW.transactionId,provenTxReqId,1,provenTxId,NEW.userId FROM proven_tx_reqs WHERE txid=NEW.txid AND provenTxId IS NOT NULL')}` + } + // DECLARE belongs before the optional IF in a MySQL trigger body. + trigger('tx_insert', 'transactions', 'AFTER', 'INSERT', declarations + txnBody) + if (isMysql) + trigger( + 'tx_after_update', + 'transactions', + 'AFTER', + 'UPDATE', + `${declarations} IF ${txnChanged} THEN ${txnBody} END IF;` + ) + else trigger('tx_after_update', 'transactions', 'AFTER', 'UPDATE', txnBody, txnChanged) + const current = isMysql ? ' FOR SHARE' : '' + const ensureReqProof = isMysql + ? `IF NEW.provenTxId IS NOT NULL THEN ${ensureProof('NEW.provenTxId')} END IF;` + : ensureProof('NEW.provenTxId') + const requestEdges = `SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId${current}` + const proofEdges = `SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId${current}` + const reqBody = `${ensureReqProof} ${edgeInsert(requestEdges)} ${edgeInsert(proofEdges)} DELETE FROM ${guards} WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM ${keys} WHERE tableId=1 AND rowId=NEW.provenTxId);` + trigger('req_insert', 'proven_tx_reqs', 'AFTER', 'INSERT', reqBody) + trigger('req_after_update', 'proven_tx_reqs', 'AFTER', 'UPDATE', reqBody, reqChanged) + return statements +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts index 9d5757ccb..7a023c8e1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -67,7 +67,7 @@ test('a ready request waits for owned reader destruction while foreground storag const source = (await storage.openSnapshotArchiveSource(identity))! expect(source.user.identityKey).toBe(identity) expect(source.sourceStorage.storageIdentityKey).toBe('original-source') - expect(source.sourceSchema).toBe('2026-10-01-005 add snapshot certificate field key indexes') + expect(source.sourceSchema).toBe('2026-10-01-006 add snapshot global reference indexes') const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex expect(reader.knex).not.toBe(storage.knex) expect(reader.knex.client.config.pool).toMatchObject({ min: 0, max: 1 }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index 082d2dac3..12a6df5ab 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -1,3 +1,4 @@ +import { readSnapshotGlobalIndexState } from '../schema/snapshotGlobalIndexMigration' import { readSnapshotCertificateIndexState } from '../schema/snapshotCertificateIndexMigration' import { readSnapshotRelationIndexState } from '../schema/snapshotRelationIndexMigration' import { readSnapshotProfileIndexState } from '../schema/snapshotProfileIndexMigration' @@ -130,6 +131,70 @@ function owned(k: Knex, table: string, id: string, source: string, userId: numbe .whereRaw('?? = ??', [`${table}.${id}`, source]) } +const globalTableIds: Partial> = { provenTxReqs: 0, provenTxs: 1 } +function relationSourceQuery( + k: Knex, + table: WalletSnapshotTable, + userId: number, + relationId: number +): Knex.QueryBuilder { + const { name } = definitions[table] + + const [left, right] = definitions[table].keys + // The maintenance index begins with the same profile prefix. MySQL can + // choose it and sort the complete profile before LIMIT; bind paging to the + // auxiliary primary key whose suffix is the unchanged cursor order. + const relationKeys = String(k.client.config.client).includes('mysql') + ? k.raw('?? FORCE INDEX (??)', ['snapshot_relation_keys', 'PRIMARY']) + : 'snapshot_relation_keys' + const query = k(relationKeys) + .crossJoin(name, function () { + void this.on('snapshotLeftId', '=', `${name}.${left}`).andOn('snapshotRightId', '=', `${name}.${right}`) + }) + .where({ snapshotTableId: relationId, snapshotUserId: userId }) + // Read the recorded membership and keep source lookups indexed even before + // InnoDB has refreshed cardinality statistics after bootstrap or bulk writes. + if (String(k.client.config.client).includes('mysql')) + void query.whereBetween('snapshotMembership', [1, 3]).hintComment(['JOIN_FIXED_ORDER()', `JOIN_INDEX(${name})`]) + return query +} +function certificateSourceQuery(k: Knex, userId: number): Knex.QueryBuilder { + const name = 'certificate_fields' + + const mysql = String(k.client.config.client).includes('mysql') + const keys = mysql + ? k.raw('?? FORCE INDEX (??)', ['snapshot_certificate_field_keys', 'PRIMARY']) + : 'snapshot_certificate_field_keys' + const query = k(keys) + .crossJoin(name, function () { + void this.on('snapshotFieldName', '=', `${name}.fieldName`).andOn( + 'snapshotCertificateId', + '=', + `${name}.certificateId` + ) + }) + .where('snapshotUserId', userId) + if (mysql) + void query + .whereBetween('snapshotMembership', [1, 3]) + .hintComment(['JOIN_FIXED_ORDER()', 'JOIN_INDEX(certificate_fields)']) + return query +} +function globalSourceQuery(k: Knex, table: WalletSnapshotTable, userId: number, globalId: number): Knex.QueryBuilder { + const { name } = definitions[table] + + const mysql = String(k.client.config.client).includes('mysql') + const keys = mysql + ? k.raw('?? FORCE INDEX (??)', ['snapshot_global_keys', 'snapshot_global_page']) + : 'snapshot_global_keys' + const query = k(keys) + .crossJoin(name, 'rowId', `${name}.${definitions[table].keys[0]}`) + .where({ tableId: globalId, userId, present: 1 }) + .where('refs', '>', 0) + if (mysql) void query.hintComment(['JOIN_FIXED_ORDER()', `JOIN_INDEX(${name})`]) + return query +} + /** Shared profile selection for local paging and archive closure checks. */ export function walletSnapshotSourceQuery( k: Knex, @@ -137,7 +202,8 @@ export function walletSnapshotSourceQuery( userId: number, profileIndexes = false, relationIndexes = false, - certificateIndexes = false + certificateIndexes = false, + globalIndexes = false ): Knex.QueryBuilder { const { name } = definitions[table] const tableId = auxiliaryTableIds[table] @@ -147,45 +213,10 @@ export function walletSnapshotSourceQuery( .where({ snapshotTableId: tableId, snapshotUserId: userId }) .where(`${name}.userId`, userId) const relationId = auxiliaryRelationTableIds[table] - if (relationIndexes && relationId !== undefined) { - const [left, right] = definitions[table].keys - // The maintenance index begins with the same profile prefix. MySQL can - // choose it and sort the complete profile before LIMIT; bind paging to the - // auxiliary primary key whose suffix is the unchanged cursor order. - const relationKeys = String(k.client.config.client).includes('mysql') - ? k.raw('?? FORCE INDEX (??)', ['snapshot_relation_keys', 'PRIMARY']) - : 'snapshot_relation_keys' - const query = k(relationKeys) - .crossJoin(name, function () { - void this.on('snapshotLeftId', '=', `${name}.${left}`).andOn('snapshotRightId', '=', `${name}.${right}`) - }) - .where({ snapshotTableId: relationId, snapshotUserId: userId }) - // Read the recorded membership and keep source lookups indexed even before - // InnoDB has refreshed cardinality statistics after bootstrap or bulk writes. - if (String(k.client.config.client).includes('mysql')) - void query.whereBetween('snapshotMembership', [1, 3]).hintComment(['JOIN_FIXED_ORDER()', `JOIN_INDEX(${name})`]) - return query - } - if (certificateIndexes && table === 'certificateFields') { - const mysql = String(k.client.config.client).includes('mysql') - const keys = mysql - ? k.raw('?? FORCE INDEX (??)', ['snapshot_certificate_field_keys', 'PRIMARY']) - : 'snapshot_certificate_field_keys' - const query = k(keys) - .crossJoin(name, function () { - void this.on('snapshotFieldName', '=', `${name}.fieldName`).andOn( - 'snapshotCertificateId', - '=', - `${name}.certificateId` - ) - }) - .where('snapshotUserId', userId) - if (mysql) - void query - .whereBetween('snapshotMembership', [1, 3]) - .hintComment(['JOIN_FIXED_ORDER()', 'JOIN_INDEX(certificate_fields)']) - return query - } + if (relationIndexes && relationId !== undefined) return relationSourceQuery(k, table, userId, relationId) + if (certificateIndexes && table === 'certificateFields') return certificateSourceQuery(k, userId) + const globalId = globalTableIds[table] + if (globalIndexes && globalId !== undefined) return globalSourceQuery(k, table, userId, globalId) const query = k(name) if (table === 'provenTxReqs') { return query.whereExists(owned(k, 'transactions', 'txid', `${name}.txid`, userId)) @@ -295,6 +326,7 @@ interface SnapshotContext { profileIndexes: boolean relationIndexes: boolean certificateIndexes: boolean + globalIndexes: boolean columns: Map } @@ -329,7 +361,8 @@ async function readPage( after: Array | undefined, limits: { maxRows: number; maxBytes: number } ): Promise> { - const { storage, userId, columns, snapshotId, profileIndexes, relationIndexes, certificateIndexes } = context + const { storage, userId, columns, snapshotId, profileIndexes, relationIndexes, certificateIndexes, globalIndexes } = + context const k = storage.toDb(trx) const schema = definitions[table] let fields = columns.get(table) @@ -343,8 +376,17 @@ async function readPage( if (relationIndexes && auxiliaryRelationTableIds[table] !== undefined) orderKeys = ['snapshotLeftId', 'snapshotRightId'] if (certificateIndexes && table === 'certificateFields') orderKeys = ['snapshotFieldName', 'snapshotCertificateId'] + if (globalIndexes && (table === 'provenTxs' || table === 'provenTxReqs')) orderKeys = ['rowId'] const base = (): Knex.QueryBuilder => { - const q = walletSnapshotSourceQuery(k, table, userId, profileIndexes, relationIndexes, certificateIndexes) + const q = walletSnapshotSourceQuery( + k, + table, + userId, + profileIndexes, + relationIndexes, + certificateIndexes, + globalIndexes + ) if (after !== undefined) seek(q, orderKeys, after, false, storage.dbtype === 'MySQL') for (const key of orderKeys) void q.orderBy(key) return q @@ -379,7 +421,8 @@ export function createKnexWalletSnapshotPageReader( view: RetainedReadSnapshot, profileIndexes = false, relationIndexes = false, - certificateIndexes = false + certificateIndexes = false, + globalIndexes = false ): WalletReadSnapshot['readPage'] { const context: SnapshotContext = { storage, @@ -388,6 +431,7 @@ export function createKnexWalletSnapshotPageReader( profileIndexes, relationIndexes, certificateIndexes, + globalIndexes, columns: new Map() } return async ( @@ -414,20 +458,26 @@ export async function openKnexWalletReadSnapshot( } const view = await storage.openReadSnapshot(options) try { - const { header, profileIndexes, relationIndexes, certificateIndexes } = await view.read(async trx => { - const sourceStorage = await storage.readSettings(trx) - const user = await storage.findUserByIdentityKey(identityKey, trx) - if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') - return { - header: { sourceStorage, user }, - profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - relationIndexes: await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - certificateIndexes: await readSnapshotCertificateIndexState( - storage.toDb(trx), - storage.knex.client.config.migrations - ) + const { header, profileIndexes, relationIndexes, certificateIndexes, globalIndexes } = await view.read( + async trx => { + const sourceStorage = await storage.readSettings(trx) + const user = await storage.findUserByIdentityKey(identityKey, trx) + if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') + return { + header: { sourceStorage, user }, + profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), + relationIndexes: await readSnapshotRelationIndexState( + storage.toDb(trx), + storage.knex.client.config.migrations + ), + globalIndexes: await readSnapshotGlobalIndexState(storage.toDb(trx), storage.knex.client.config.migrations), + certificateIndexes: await readSnapshotCertificateIndexState( + storage.toDb(trx), + storage.knex.client.config.migrations + ) + } } - }) + ) const userId = header.user.userId const snapshotId = Utils.toHex(Random(32)) return { @@ -447,7 +497,8 @@ export async function openKnexWalletReadSnapshot( view, profileIndexes, relationIndexes, - certificateIndexes + certificateIndexes, + globalIndexes ) } } catch (error) { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.integration.test.ts new file mode 100644 index 000000000..7f32e0839 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.integration.test.ts @@ -0,0 +1,230 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex, type Knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { runInSeries } from '../../utility/runInSeries' +import { seedArchiveClosure } from '../../../test/utils/snapshotArchiveFixtures' +import { snapshotArchiveTables } from './archive/SnapshotArchive' +import { openKnexSnapshotArchiveSource, type SnapshotArchiveSource } from './archive/KnexSnapshotArchiveSource' +import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' +import { + addSnapshotGlobalIndexes, + removeSnapshotGlobalIndexes, + SNAPSHOT_GLOBAL_INDEX_MIGRATION +} from '../schema/snapshotGlobalIndexMigration' + +const identity = '02' + '11'.repeat(32) +const stores: StorageKnex[] = [] +const writers: Knex[] = [] +const directories: string[] = [] +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-global-index-')) + directories.push(directory) + const options = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + } + const k = knex(options) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + stores.push(source) + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('global index fixture', 'synthetic-global-index') + await source.makeAvailable() + // Start with the immediately preceding schema on both old and new source. + await removeSnapshotGlobalIndexes(k) + await k('knex_migrations').where('name', SNAPSHOT_GLOBAL_INDEX_MIGRATION).delete() + const { user } = await source.findOrInsertUser(identity) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, other.userId) + const writer = knex(options) + writers.push(writer) + return { source, writer, k, userId: user.userId, otherId: other.userId } +} +async function journal(k: Knex): Promise { + await k('knex_migrations').insert({ + name: SNAPSHOT_GLOBAL_INDEX_MIGRATION, + batch: 99, + migration_time: new Date() + }) +} +async function pages(view: WalletReadSnapshot | SnapshotArchiveSource) { + expect(Object.hasOwn(view, 'globalIndexes')).toBe(false) + if ('validateClosure' in view) await view.validateClosure() + const result: Record = {} + await runInSeries(snapshotArchiveTables, async table => { + let cursor: WalletSnapshotCursor | undefined + const selected: unknown[] = [] + let complete = false + for (let pageNumber = 0; pageNumber < 25 && !complete; pageNumber++) { + const page = await view.readPage(table, cursor, { maxRows: 1, maxBytes: 131072 }) + selected.push({ rows: page.rows, after: page.cursor?.after, payloadBytes: page.payloadBytes, done: page.done }) + complete = page.done + cursor = page.cursor + } + expect(complete).toBe(true) + result[table] = selected + }) + return result +} +afterEach(async () => { + await runInSeries(writers.splice(0), k => k.destroy()) + await runInSeries(stores.splice(0), source => source.destroy()) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) + +test('indexed ordinary/archive pages preserve all13tables and original source indexes/legacy offsets', async () => { + const { source, k, userId } = await fixture() + const legacy = async () => { + const all: Record>> = {} + await runInSeries( + [ + ['findTransactions', 'transactionId'], + ['findOutputs', 'outputId'], + ['findCertificates', 'certificateId'], + ['findTxLabels', 'txLabelId'], + ['findOutputBaskets', 'basketId'], + ['findOutputTags', 'outputTagId'], + ['findCommissions', 'commissionId'], + ['findSyncStates', 'syncStateId'] + ] as const, + async ([method, key]) => { + all[method] = [] + await runInSeries([0, 1], async offset => { + const rows = await source[method]({ partial: { userId }, paged: { limit: 1, offset } }) + all[method].push(rows.map(row => (row as unknown as Record)[key])) + }) + } + ) + all.certificateFields = [] + await runInSeries([0, 1], async offset => { + const rows = await source.findCertificateFields({ partial: { userId }, paged: { limit: 1, offset } }) + all.certificateFields.push(rows.flatMap(row => [row.fieldName, row.certificateId])) + }) + return all + } + const standard = async () => + await k('sqlite_master') + .whereIn('type', ['table', 'index']) + .whereNotIn('tbl_name', [ + 'snapshot_global_keys', + 'snapshot_global_edges', + 'snapshot_global_guards', + 'snapshot_global_index_progress' + ]) + .select('type', 'name', 'tbl_name', 'sql') + .orderBy('name') + const originalIndexes = await standard() + const originalOffsets = await legacy() + const old = await source.openWalletReadSnapshot(identity) + let baseline + try { + baseline = await pages(old) + } finally { + await old.close() + } + await addSnapshotGlobalIndexes(k) + expect(await standard()).toEqual(originalIndexes) + expect(await legacy()).toEqual(originalOffsets) + const queries: Array<{ sql: string; bindings: Knex.RawBinding[] }> = [] + const listen = (query: { sql: string; bindings: Knex.RawBinding[] }): void => { + if ( + query.sql.startsWith('select') && + query.sql.includes('cross join') && + query.sql.includes('snapshot_global_keys') + ) + queries.push(query) + } + k.on('query', listen) + try { + const partial = await source.openWalletReadSnapshot(identity) + try { + expect(await pages(partial)).toEqual(baseline) + expect(queries).toEqual([]) + } finally { + await partial.close() + } + await journal(k) + await runInSeries( + [() => source.openWalletReadSnapshot(identity), () => openKnexSnapshotArchiveSource(source, identity)], + async open => { + const view = await open() + try { + expect(await pages(view)).toEqual(baseline) + } finally { + await view.close() + } + } + ) + expect(queries.length).toBeGreaterThan(0) + } finally { + k.off('query', listen) + } + // Explain page queries; complete closure validation also inspects ownership relations. + await runInSeries( + queries.filter(query => query.sql.includes('__snapshotBytes') || query.sql.includes('.*')), + async query => { + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) + expect(plan.some(row => /SEARCH snapshot_global_keys USING (COVERING )?INDEX/.test(row.detail))).toBe(true) + expect(plan.some(row => /SCAN |TEMP B-TREE/.test(row.detail))).toBe(false) + } + ) +}) + +test.each(['ordinary', 'archive'] as const)( + '%s mode and all13table pages stay bound across independent journal/profile/progress writes', + async kind => { + const { source, k, writer } = await fixture() + await addSnapshotGlobalIndexes(k) + const open = async () => + kind === 'ordinary' + ? await source.openWalletReadSnapshot(identity) + : await openKnexSnapshotArchiveSource(source, identity) + const queries: string[] = [] + const listen = (query: { sql: string }): void => { + if ( + query.sql.startsWith('select') && + query.sql.includes('cross join') && + query.sql.includes('snapshot_global_keys') + ) + queries.push(query.sql) + } + k.on('query', listen) + const old = await open() + let baseline + try { + baseline = await pages(old) + queries.length = 0 + await journal(writer) + expect(await pages(old)).toEqual(baseline) + expect(queries).toEqual([]) + } finally { + await old.close() + } + const indexed = await open() + try { + await writer.transaction(async trx => { + await trx('snapshot_global_index_progress').where('id', 0).update({ complete: 0 }) + await trx('transactions').where('transactionId', 1).update({ provenTxId: 2 }) + await trx('proven_tx_reqs').where('provenTxReqId', 1).update({ provenTxId: 2 }) + }) + expect(await pages(indexed)).toEqual(baseline) + expect(queries.length).toBeGreaterThan(0) + } finally { + await indexed.close() + k.off('query', listen) + } + await expect(open()).rejects.toThrow('migration is incomplete') + await writer('snapshot_global_index_progress').where('id', 0).update({ complete: 1 }) + const fresh = await open() + try { + expect((await fresh.readPage('provenTxs')).rows.map(row => row.provenTxId)).toEqual([2, 3]) + expect((await fresh.readPage('provenTxReqs')).rows.map(row => row.provenTxId)).toEqual([2, 3]) + } finally { + await fresh.close() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.mysql.test.ts new file mode 100644 index 000000000..0892ff0fe --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.mysql.test.ts @@ -0,0 +1,519 @@ +import { knex } from 'knex' +import { + addSnapshotGlobalIndexes as install, + removeSnapshotGlobalIndexes as remove, + readSnapshotGlobalIndexState as enabled, + SNAPSHOT_GLOBAL_INDEX_MIGRATION as migration +} from '../schema/snapshotGlobalIndexMigration' + +type Kind = 'engine' | 'rules' | 'sourceColumns' | 'columns' | 'indexes' | 'triggers' +type Metadata = Array> +interface Edge { + transactionId: number + requestId: number + tableId: number + rowId: number + userId: number +} + +// Exercise the installed MySQL query compiler and transaction protocol. Native +// fixtures separately establish trigger effects, concurrency and query bounds. +function fixture(change: (kind: Kind, table: string, rows: Metadata) => unknown = (_kind, _table, rows) => rows) { + const k = knex({ client: 'mysql2' }) + const queries: Array<{ sql: string; values: unknown[] }> = [] + const tables = new Set(['knex_migrations']) + const indexes = new Map>() + const triggers = new Map>() + const edges: Edge[] = [] + const guards = new Set() + let journaled = false + let progress: { id: number; afterRowId: number; complete: boolean | number } | undefined + const columns: Record> = { + proven_txs: [['provenTxId', 'int unsigned', 'NO']], + proven_tx_reqs: [ + ['provenTxReqId', 'int unsigned', 'NO'], + ['provenTxId', 'int unsigned', 'YES'], + ['txid', 'varchar(64)', 'NO'] + ], + transactions: [ + ['transactionId', 'int unsigned', 'NO'], + ['userId', 'int unsigned', 'NO'], + ['provenTxId', 'int unsigned', 'YES'], + ['txid', 'varchar(64)', 'YES'] + ], + snapshot_global_guards: [ + ['proofId', 'int unsigned', 'NO'], + ['present', 'tinyint(1)', 'NO'] + ], + snapshot_global_keys: [ + ['tableId', 'int', 'NO'], + ['userId', 'int unsigned', 'NO'], + ['rowId', 'int unsigned', 'NO'], + ['refs', 'bigint unsigned', 'NO'], + ['present', 'tinyint(1)', 'NO'] + ], + snapshot_global_edges: [ + ['transactionId', 'int unsigned', 'NO'], + ['requestId', 'int unsigned', 'NO'], + ['tableId', 'int', 'NO'], + ['rowId', 'int unsigned', 'NO'], + ['userId', 'int unsigned', 'NO'] + ], + snapshot_global_index_progress: [ + ['id', 'int', 'NO'], + ['afterRowId', 'int unsigned', 'NO'], + ['complete', 'tinyint(1)', 'NO'] + ] + } + const primary: Record = { + proven_txs: ['provenTxId'], + proven_tx_reqs: ['provenTxReqId'], + transactions: ['transactionId'], + snapshot_global_guards: ['proofId'], + snapshot_global_keys: ['tableId', 'userId', 'rowId'], + snapshot_global_edges: ['transactionId', 'requestId', 'tableId', 'rowId'], + snapshot_global_index_progress: ['id'] + } + const answer = (sql: string, values: unknown[] = []): unknown => { + queries.push({ sql, values }) + const table = String(values[0]) + if (sql.startsWith('SELECT ENGINE AS engine')) return change('engine', table, [{ engine: 'InnoDB' }]) + if (sql.startsWith('SELECT UPDATE_RULE')) + return change('rules', table, [{ updateRule: 'RESTRICT', deleteRule: 'NO ACTION' }]) + if (sql.startsWith('SELECT COLUMN_NAME')) { + const source = !table.startsWith('snapshot_') + return change( + source ? 'sourceColumns' : 'columns', + table, + columns[table].map(([name, type, nullable], i) => ({ + name, + type, + nullable, + defaultValue: null, + extra: source && i === 0 ? 'auto_increment' : '', + charset: type === 'varchar(64)' ? 'utf8mb4' : null, + collation: type === 'varchar(64)' ? 'utf8mb4_0900_ai_ci' : null + })) + ) + } + if (sql.startsWith('SELECT INDEX_NAME AS')) { + const part = (name: string, columnName: string, nonUnique: number) => ({ + name, + columnName, + nonUnique, + direction: 'A', + prefix: null + }) + return change('indexes', table, [ + ...primary[table].map(column => part('PRIMARY', column, 0)), + ...(['transactions', 'proven_tx_reqs'].includes(table) + ? [part('source_txid', 'txid', table === 'transactions' ? 1 : 0)] + : []), + ...[...(indexes.get(table) ?? [])].flatMap(([name, fields]) => fields.map(field => part(name, field, 1))) + ]) + } + if (sql.startsWith('select * from information_schema.tables')) + return tables.has(table) ? [{ TABLE_NAME: table }] : [] + if (sql.startsWith('create table')) { + tables.add(sql.match(/^create table `([^`]+)`/)![1]) + return [] + } + if (sql.startsWith('drop table')) { + tables.delete(sql.match(/`([^`]+)`/)![1]) + return [] + } + if (sql.startsWith('alter table')) { + const [, name, index] = sql.match(/^alter table `([^`]+)` add index `([^`]+)`/)! + const found = indexes.get(name) ?? new Map() + found.set( + index, + [...sql.matchAll(/`([^`]+)`/g)].slice(2).map(match => match[1]) + ) + indexes.set(name, found) + return [] + } + if (sql.startsWith('SELECT EVENT_MANIPULATION')) + return change('triggers', table, triggers.has(table) ? [triggers.get(table)!] : []) + if (sql.startsWith('CREATE TRIGGER')) { + const [, name, timing, event, tableName, body] = sql.match( + /^CREATE TRIGGER (\w+) (BEFORE|AFTER) (\w+) ON (\w+) FOR EACH ROW (.*)$/ + )! + triggers.set(name, { event, timing, tableName, body }) + return [] + } + if (sql.startsWith('DROP TRIGGER')) { + triggers.delete(sql.split(' ').at(-1)!.replaceAll('`', '')) + return [] + } + if (sql.startsWith('insert ignore into `snapshot_global_index_progress`')) { + expect(values).toEqual([0, false, 0]) + progress ??= { id: 0, afterRowId: 0, complete: false } + return { affectedRows: 1, insertId: 0 } + } + if (sql.startsWith('select * from `snapshot_global_index_progress`')) { + if (sql.includes('where')) { + expect(sql).toMatch(/for update$/) + expect(values).toEqual([0, 1]) + } else expect(values).toEqual([2]) + return progress === undefined ? [] : [{ ...progress }] + } + if (sql.startsWith('update `snapshot_global_index_progress`')) { + expect(values[2]).toBe(0) + Object.assign(progress!, { afterRowId: values[0], complete: values[1] }) + return { affectedRows: 1 } + } + if (sql.startsWith('select `transactionId` from `transactions`')) { + expect(sql).toContain('`transactionId` <= ?') + expect(sql).toMatch(/for update$/) + expect(values).toEqual([0, 1]) + return [] + } + if (sql.startsWith('select `transactionId`, `userId`, `provenTxId`')) { + expect(sql).toContain('CASE WHEN octet_length(txid) <= 256 THEN txid END AS txid') + expect(sql).toContain('COALESCE(octet_length(txid),0) AS txidBytes') + expect(sql).toContain('where `transactionId` > ? order by `transactionId` asc limit ? for update') + expect(values[1]).toBe(256) + return Array.from({ length: 257 }, (_, i) => ({ + transactionId: i + 1, + userId: (i % 2) + 1, + provenTxId: i % 3 ? 7 : null, + txid: i % 2 ? 'a' : null, + txidBytes: i % 2 ? 1 : 0 + })) + .filter(row => row.transactionId > Number(values[0])) + .slice(0, Number(values[1])) + } + if (sql.startsWith('select `provenTxReqId`, `provenTxId`')) { + expect(sql).toContain('from `proven_tx_reqs` where `txid` = ? limit ? lock in share mode') + expect(values).toEqual(['a', 1]) + return [{ provenTxReqId: 4294967294, provenTxId: 4294967295 }] + } + if (sql.startsWith('insert into `snapshot_global_guards`')) { + expect(sql).toContain('on duplicate key update `proofId` = `proofId`') + expect(values[0]).toBe(false) + guards.add(Number(values[1])) + return { affectedRows: 1 } + } + if (sql.startsWith('UPDATE snapshot_global_guards')) { + expect(sql).toBe( + 'UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=?' + ) + expect(guards.has(Number(values[0]))).toBe(true) + return { affectedRows: 1 } + } + if (sql.startsWith('insert into `snapshot_global_edges`')) { + expect(sql).toContain('on duplicate key update `transactionId` = `transactionId`') + const fields = sql + .slice(sql.indexOf('(') + 1, sql.indexOf(')')) + .split(', ') + .map(value => value.replaceAll('`', '')) + for (let i = 0; i < values.length; i += fields.length) { + const row = Object.fromEntries(fields.map((field, offset) => [field, values[i + offset]])) as unknown as Edge + if ( + !edges.some( + edge => + edge.transactionId === row.transactionId && + edge.requestId === row.requestId && + edge.tableId === row.tableId && + edge.rowId === row.rowId + ) + ) + edges.push(row) + } + return { affectedRows: 1 } + } + if (sql.startsWith('select `name` from `knex_migrations`')) { + expect(values).toEqual([migration, 1]) + return journaled ? [{ name: migration }] : [] + } + if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return [] + throw new Error('Unexpected synthetic driver query: ' + sql) + } + const connection = { + query( + query: { sql: string }, + values: unknown[], + callback: (error: Error | null, rows?: unknown, fields?: unknown[]) => void + ) { + try { + callback(null, answer(query.sql, values), []) + } catch (error) { + callback(error as Error) + } + } + } + jest.spyOn(k.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(k.client, 'releaseConnection').mockResolvedValue(undefined) + return { + k, + queries, + tables, + indexes, + triggers, + edges, + progress: () => progress, + setJournaled: () => { + journaled = true + } + } +} +afterEach(() => jest.restoreAllMocks()) + +test('MySQL bootstrap uses current locks, bounded pages and idempotent reference edges before adoption', async () => { + const f = fixture() + try { + expect(await enabled(f.k)).toBe(false) + await install(f.k) + await install(f.k) + expect(f.progress()).toEqual({ id: 0, afterRowId: 257, complete: true }) + expect(f.edges).toHaveLength(427) + expect(f.edges.filter(edge => edge.tableId === 0)).toHaveLength(128) + expect(f.edges.filter(edge => edge.requestId === 0)).toHaveLength(171) + expect(f.edges.find(edge => edge.transactionId === 2 && edge.requestId !== 0 && edge.tableId === 1)).toEqual({ + transactionId: 2, + requestId: 4294967294, + tableId: 1, + rowId: 4294967295, + userId: 2 + }) + expect(f.triggers.size).toBe(14) + const ddl = f.queries.filter(q => q.sql.startsWith('CREATE TRIGGER')).map(q => q.sql) + expect(ddl[0]).toContain('snapshot_global_edge_delete') + expect(ddl[1]).toContain('snapshot_global_edge_insert') + expect(ddl.findIndex(sql => sql.includes('snapshot_global_tx_before_update'))).toBeLessThan( + ddl.findIndex(sql => sql.includes('snapshot_global_tx_insert')) + ) + expect(ddl.findIndex(sql => sql.includes('snapshot_global_req_before_update'))).toBeLessThan( + ddl.findIndex(sql => sql.includes('snapshot_global_req_insert')) + ) + expect(ddl.filter(sql => sql.includes('DECLARE requestedId INT UNSIGNED'))).toHaveLength(2) + expect(ddl.filter(sql => sql.includes('DECLARE requestedProof INT UNSIGNED'))).toHaveLength(2) + expect( + ddl.filter(sql => sql.includes('SELECT present FROM snapshot_global_guards WHERE proofId=NEW.rowId FOR SHARE')) + ).toHaveLength(1) + expect(ddl.filter(sql => sql.includes('ORDER BY transactionId FOR SHARE'))).toHaveLength(2) + expect(f.queries.filter(q => q.sql.startsWith('create table')).every(q => q.sql.includes('engine = InnoDB'))).toBe( + true + ) + expect(await enabled(f.k)).toBe(false) + f.setJournaled() + expect(await enabled(f.k)).toBe(true) + await remove(f.k) + await remove(f.k) + expect(f.tables).toEqual(new Set(['knex_migrations'])) + expect(f.triggers.size).toBe(0) + } finally { + await f.k.destroy() + } +}) + +test.each(['CASCADE', 'SET NULL', 'SET DEFAULT'])( + 'MySQL refuses implicit %s updates or deletes before DDL', + async rule => { + for (const field of ['updateRule', 'deleteRule']) { + const f = fixture((kind, _table, rows) => (kind === 'rules' ? [{ ...rows[0], [field]: rule }] : rows)) + try { + await expect(install(f.k)).rejects.toThrow('requires explicit row mutations') + await expect(remove(f.k)).rejects.toThrow('requires explicit row mutations') + expect(f.queries.some(q => /^(create|alter|drop|insert|update|delete)/i.test(q.sql))).toBe(false) + } finally { + await f.k.destroy() + } + } + } +) + +test.each([ + ['engine', [], 'requires transactional tables'], + ['engine', [{ engine: 'MyISAM' }], 'requires transactional tables'], + ['rules', null, 'requires explicit row mutations'], + ['sourceColumns', [], 'Unsupported snapshot global source column'], + ['indexes', null, 'Invalid snapshot global index metadata'], + ['indexes', [], 'Unsupported snapshot global source key'] +] as Array<[Kind, unknown, string]>)( + 'malformed source %s metadata refuses before migration', + async (kind, value, message) => { + const f = fixture((current, _table, rows) => (current === kind ? value : rows)) + try { + await expect(install(f.k)).rejects.toThrow(message) + await expect(remove(f.k)).rejects.toThrow(message) + } finally { + await f.k.destroy() + } + } +) + +test.each([{ type: 'bigint unsigned' }, { nullable: 'YES' }, { extra: 'VIRTUAL GENERATED' }, { name: 'other' }])( + 'MySQL source key metadata %j is refused', + async changed => { + const f = fixture((kind, _table, rows) => + kind === 'sourceColumns' ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows + ) + try { + await expect(install(f.k)).rejects.toThrow('Unsupported snapshot global source column') + } finally { + await f.k.destroy() + } + } +) + +test.each([ + { charset: null }, + { collation: null }, + { type: 'varchar(63)' }, + { nullable: 'YES' }, + { extra: 'auto_increment' } +])('MySQL request text metadata %j is refused', async changed => { + const f = fixture((kind, table, rows) => + kind === 'sourceColumns' && table === 'proven_tx_reqs' + ? rows.map(row => (row.name === 'txid' ? { ...row, ...changed } : row)) + : rows + ) + try { + await expect(install(f.k)).rejects.toThrow('Unsupported snapshot global source') + } finally { + await f.k.destroy() + } +}) + +test.each([{ charset: 'latin1' }, { collation: 'utf8mb4_bin' }])( + 'MySQL incompatible source text %j is refused', + async changed => { + const f = fixture((kind, table, rows) => + kind === 'sourceColumns' && table === 'transactions' + ? rows.map(row => (row.name === 'txid' ? { ...row, ...changed } : row)) + : rows + ) + try { + await expect(install(f.k)).rejects.toThrow('require matching text definitions') + } finally { + await f.k.destroy() + } + } +) + +test.each([{ columnName: 'other' }, { direction: 'D' }, { prefix: 10 }, { nonUnique: 1 }])( + 'MySQL incomplete request lookup %j is refused', + async changed => { + const f = fixture((kind, table, rows) => + kind === 'indexes' && table === 'proven_tx_reqs' + ? rows.map(row => (row.name === 'source_txid' ? { ...row, ...changed } : row)) + : rows + ) + try { + await expect(install(f.k)).rejects.toThrow('requires complete transaction lookup indexes') + } finally { + await f.k.destroy() + } + } +) + +test.each([ + { name: 'other' }, + { type: 'bigint unsigned' }, + { nullable: 'YES' }, + { defaultValue: 0 }, + { extra: 'auto_increment' } +])('MySQL auxiliary column %j cannot be adopted', async changed => { + const f = fixture((kind, _table, rows) => + kind === 'columns' ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows + ) + try { + await expect(install(f.k)).rejects.toThrow('table definition mismatch') + } finally { + await f.k.destroy() + } +}) + +test.each([{ columnName: 'other' }, { direction: 'D' }, { prefix: 10 }, { name: 'foreign_unique' }])( + 'MySQL auxiliary index %j cannot be adopted', + async changed => { + const f = fixture((kind, table, rows) => + kind === 'indexes' && table.startsWith('snapshot_') ? [{ ...rows[0], ...changed }, ...rows.slice(1)] : rows + ) + try { + await expect(install(f.k)).rejects.toThrow('table definition mismatch') + } finally { + await f.k.destroy() + } + } +) + +test.each([{ event: 'UPDATE' }, { timing: 'BEFORE' }, { tableName: 'other' }, { body: 'BEGIN SELECT 1; END' }])( + 'MySQL altered trigger %j refuses removal before dropping objects', + async changed => { + let corrupt = false + const f = fixture((kind, _table, rows) => + corrupt && kind === 'triggers' && rows.length ? [{ ...rows[0], ...changed }] : rows + ) + try { + await install(f.k) + corrupt = true + await expect(install(f.k)).rejects.toThrow('trigger definition mismatch') + await expect(remove(f.k)).rejects.toThrow('trigger definition mismatch') + expect(f.triggers.size).toBe(14) + } finally { + await f.k.destroy() + } + } +) + +test('MySQL equivalent trigger whitespace resumes without replacing observers', async () => { + let reformatted = false + const f = fixture((kind, _table, rows) => + reformatted && kind === 'triggers' + ? rows.map(row => ({ ...row, body: '\n ' + String(row.body).replaceAll(' ', '\t \n') + '\n' })) + : rows + ) + try { + await install(f.k) + reformatted = true + await install(f.k) + expect(f.queries.filter(q => q.sql.startsWith('CREATE TRIGGER'))).toHaveLength(14) + await remove(f.k) + expect(f.triggers.size).toBe(0) + } finally { + await f.k.destroy() + } +}) + +test('MySQL refuses nested migration transactions before any database work', async () => { + const f = fixture() + try { + Object.defineProperty(f.k, 'isTransaction', { value: true }) + await expect(install(f.k)).rejects.toThrow('independent DDL') + await expect(remove(f.k)).rejects.toThrow('independent DDL') + expect(f.queries).toEqual([]) + } finally { + await f.k.destroy() + } +}) + +test.each([{ rows: [] }, { rows: [{ engine: 'MyISAM' }] }])( + 'MySQL auxiliary engine metadata %j refuses adoption', + async ({ rows: value }) => { + const f = fixture((kind, table, rows) => (kind === 'engine' && table.startsWith('snapshot_') ? value : rows)) + try { + await expect(install(f.k)).rejects.toThrow('table definition mismatch') + } finally { + await f.k.destroy() + } + } +) + +test.each( + [ + null, + [ + { event: 'DELETE', timing: 'AFTER', tableName: 'snapshot_global_edges', body: 'BEGIN END' }, + { event: 'DELETE', timing: 'AFTER', tableName: 'snapshot_global_edges', body: 'BEGIN END' } + ] + ].map(rows => ({ rows })) +)('MySQL malformed trigger metadata refuses %j', async ({ rows: value }) => { + const f = fixture((kind, _table, rows) => (kind === 'triggers' ? value : rows)) + try { + await expect(install(f.k)).rejects.toThrow('trigger') + } finally { + await f.k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.test.ts new file mode 100644 index 000000000..fcdeaffa1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.test.ts @@ -0,0 +1,364 @@ +import type { Knex } from 'knex' +import fc from 'fast-check' +import { runInSeries } from '../../utility/runInSeries' +import { + minimalGlobalDatabase, + expectGlobalMembership, + clearGlobalSource, + applyGlobalOperation +} from '../../../test/utils/snapshotGlobalFixtures' +import { + addSnapshotGlobalIndexes as install, + removeSnapshotGlobalIndexes as remove, + readSnapshotGlobalIndexState as enabled, + SNAPSHOT_GLOBAL_INDEX_MIGRATION as migration +} from '../schema/snapshotGlobalIndexMigration' + +const databases: Knex[] = [] +async function fixture(collation = 'BINARY') { + const k = await minimalGlobalDatabase(collation) + databases.push(k) + return k +} +afterEach(async () => { + jest.restoreAllMocks() + await runInSeries(databases.splice(0), k => k.destroy()) +}) +async function journal(k: Knex, table = 'knex_migrations') { + await k.schema.createTable(table, t => { + void t.string('name') + }) + await k(table).insert({ name: migration }) +} + +test.each([0, 1, 255, 256, 257, 600])( + 'bootstrap/replay preserve %s source rows without double counting', + async count => { + const k = await fixture() + await k('proven_txs').insert([{ provenTxId: 7 }, { provenTxId: 9 }]) + await k('proven_tx_reqs').insert({ provenTxReqId: 5, txid: 'a', provenTxId: 9 }) + await runInSeries( + Array.from({ length: Math.ceil(count / 200) }, (_, i) => i * 200), + async start => { + await k('transactions').insert( + Array.from({ length: Math.min(200, count - start) }, (_, i) => ({ + transactionId: start + i + 1, + userId: (i % 2) + 1, + txid: i % 2 ? 'a' : null, + provenTxId: i % 2 ? 7 : null + })) + ) + } + ) + const schema = () => + k('sqlite_master').whereIn('type', ['table', 'index']).select('name', 'type', 'sql').orderBy('name') + const before = await schema() + expect(await enabled(k)).toBe(false) + await install(k) + await expectGlobalMembership(k) + const keys = await k('snapshot_global_keys').orderBy(['tableId', 'userId', 'rowId']) + await install(k) + expect(await k('snapshot_global_keys').orderBy(['tableId', 'userId', 'rowId'])).toEqual(keys) + await k('snapshot_global_index_progress').where('id', 0).update({ afterRowId: 0, complete: false }) + await install(k) + await expectGlobalMembership(k) + expect(await k('snapshot_global_keys').orderBy(['tableId', 'userId', 'rowId'])).toEqual(keys) + expect(await k('snapshot_global_index_progress').first()).toEqual({ id: 0, afterRowId: count, complete: 1 }) + await remove(k) + await remove(k) + expect(await schema()).toEqual(before) + expect(await k('transactions')).toHaveLength(count) + expect(await k('sqlite_master').where('type', 'trigger')).toEqual([]) + } +) + +test.each(['BINARY', 'NOCASE', 'RTRIM'])( + 'generated reference counts and presence match the independent union under %s', + async collation => { + const k = await fixture(collation) + await install(k) + const operation = fc.record({ + kind: fc.integer({ min: 0, max: 8 }), + id: fc.integer({ min: 1, max: 5 }), + userId: fc.integer({ min: 1, max: 3 }), + otherId: fc.integer({ min: 1, max: 5 }), + nullable: fc.boolean() + }) + await fc.assert( + fc.asyncProperty(fc.array(operation, { minLength: 1, maxLength: 24 }), async schedule => { + await clearGlobalSource(k) + await expectGlobalMembership(k) + await runInSeries(schedule, async op => { + await applyGlobalOperation(k, op) + await expectGlobalMembership(k) + }) + }), + { numRuns: 300, seed: 3242026 } + ) + } +) + +test('shared proofs remain owned until their final basis disappears and rolled-back changes leave no edges', async () => { + const k = await fixture() + await install(k) + await k('transactions').insert([ + { transactionId: 1, userId: 1, provenTxId: 7, txid: 'a' }, + { transactionId: 2, userId: 1, provenTxId: 7, txid: 'a' }, + { transactionId: 3, userId: 2, provenTxId: 7, txid: 'a' } + ]) + await k('proven_tx_reqs').insert({ provenTxReqId: 5, txid: 'a', provenTxId: 7 }) + await expectGlobalMembership(k) + expect((await k('snapshot_global_keys').where({ tableId: 1, userId: 1, rowId: 7 }).first()).refs).toBe(4) + expect((await k('snapshot_global_keys').where({ tableId: 1, userId: 1, rowId: 7 }).first()).present).toBe(0) + await k('proven_txs').insert({ provenTxId: 7 }) + await expectGlobalMembership(k) + await expect( + k.transaction(async trx => { + await trx('transactions').delete() + throw new Error('synthetic rollback') + }) + ).rejects.toThrow('synthetic rollback') + await expectGlobalMembership(k) + await k('transactions').where('transactionId', 1).delete() + await expectGlobalMembership(k) + expect((await k('snapshot_global_keys').where({ tableId: 1, userId: 1, rowId: 7 }).first()).refs).toBe(2) + await k('transactions').where('transactionId', 2).delete() + await expectGlobalMembership(k) + expect(await k('snapshot_global_keys').where({ tableId: 1, userId: 1 })).toEqual([]) + await k('proven_txs').where('provenTxId', 7).delete() + await expectGlobalMembership(k) + await k('proven_txs').insert({ provenTxId: 7 }) + await expectGlobalMembership(k) + await k('transactions').delete() + await expectGlobalMembership(k) + expect(await k('snapshot_global_keys')).toEqual([]) + expect(await k('snapshot_global_guards')).toEqual([]) +}) + +test.each(['BINARY', 'NOCASE', 'RTRIM'])( + 'full-width IDs and collation-equivalent text changes preserve references under %s', + async collation => { + const k = await fixture(collation) + await install(k) + await k('proven_txs').insert({ provenTxId: 4294967295 }) + await k('proven_tx_reqs').insert({ provenTxReqId: 4294967294, txid: 'A', provenTxId: 4294967295 }) + await k('transactions').insert({ transactionId: 4294967293, userId: 4294967292, txid: 'A', provenTxId: 4294967295 }) + await expectGlobalMembership(k) + await runInSeries(['a', 'a ', 'é', 'e\u0301', 'A\0B', '😀'.repeat(64)], async txid => { + await k('proven_tx_reqs').where('provenTxReqId', 4294967294).update({ txid }) + await expectGlobalMembership(k) + await k('transactions').where('transactionId', 4294967293).update({ txid }) + await expectGlobalMembership(k) + }) + await k('snapshot_global_index_progress').where('id', 0).update({ afterRowId: 0, complete: false }) + await install(k) + await expectGlobalMembership(k) + } +) + +test.each([0, -1])( + 'bootstrap refuses an initial unsupported transaction ID %s and can resume after repair', + async id => { + const k = await fixture() + await k('transactions').insert({ transactionId: id, userId: 1, txid: null, provenTxId: null }) + await expect(install(k)).rejects.toThrow('Invalid snapshot global source key') + expect((await k('snapshot_global_index_progress').first()).complete).toBe(0) + await k('transactions').where('transactionId', id).delete() + await install(k) + await expectGlobalMembership(k) + } +) + +test.each(['userId', 'provenTxId'])('bootstrap rejects unsupported %s without publishing progress', async field => { + const k = await fixture() + await k('transactions').insert({ transactionId: 1, userId: 1, txid: null, provenTxId: null, [field]: 0 }) + await expect(install(k)).rejects.toThrow('Invalid snapshot global source key') + expect((await k('snapshot_global_index_progress').first()).afterRowId).toBe(0) +}) + +test('oversized SQLite transaction text is rejected before loading it as a lookup key', async () => { + const k = await fixture() + await k('transactions').insert({ transactionId: 1, userId: 1, txid: 'a'.repeat(257), provenTxId: null }) + await expect(install(k)).rejects.toThrow('Invalid snapshot global transaction key') + expect(await k('snapshot_global_edges')).toEqual([]) + expect((await k('snapshot_global_index_progress').first()).afterRowId).toBe(0) +}) + +test.each(['knex_migrations', 'custom_migrations'])( + 'adoption requires exact owned tables and completed journal %s', + async tableName => { + const k = await fixture() + await install(k) + expect(await enabled(k, { tableName })).toBe(false) + await journal(k, tableName) + expect(await enabled(k, { tableName })).toBe(true) + await k('snapshot_global_index_progress').where('id', 0).update({ complete: false }) + await expect(enabled(k, { tableName })).rejects.toThrow('incomplete') + await install(k) + expect(await enabled(k, { tableName })).toBe(true) + await remove(k) + await expect(enabled(k, { tableName })).rejects.toThrow('incomplete') + } +) + +test('altered trigger definitions are refused by install and removal before source changes', async () => { + const k = await fixture() + await install(k) + await k.raw('DROP TRIGGER snapshot_global_tx_insert') + await k.raw('CREATE TRIGGER snapshot_global_tx_insert AFTER INSERT ON transactions BEGIN SELECT 1; END') + await expect(install(k)).rejects.toThrow('trigger definition mismatch') + await expect(remove(k)).rejects.toThrow('trigger definition mismatch') + expect(await k.schema.hasTable('snapshot_global_keys')).toBe(true) +}) + +test.each([ + 'snapshot_global_guards', + 'snapshot_global_keys', + 'snapshot_global_edges', + 'snapshot_global_index_progress' +])('an unexpected %s column is refused for resume, adoption and removal', async table => { + const k = await fixture() + await install(k) + await journal(k) + await k.schema.alterTable(table, t => { + void t.integer('unexpected') + }) + await expect(install(k)).rejects.toThrow('table definition mismatch') + await expect(enabled(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') + expect(await k.schema.hasTable('snapshot_global_keys')).toBe(true) +}) + +test.each([ + 'transactionId INTEGER NOT NULL, userId INTEGER NOT NULL, provenTxId INTEGER, txid VARCHAR(64)', + 'transactionId INTEGER NOT NULL, userId INTEGER NOT NULL, provenTxId INTEGER, txid VARCHAR(64), PRIMARY KEY(transactionId,userId)', + 'otherId INTEGER NOT NULL PRIMARY KEY, transactionId INTEGER, userId INTEGER NOT NULL, provenTxId INTEGER, txid VARCHAR(64)' +])('unsupported SQLite source key refuses before auxiliary DDL: %s', async columns => { + const k = await fixture() + await k.schema.dropTable('transactions') + await k.raw(`CREATE TABLE transactions(${columns})`) + await expect(install(k)).rejects.toThrow('Unsupported snapshot global source key') + expect(await k.schema.hasTable('snapshot_global_guards')).toBe(false) +}) + +test.each([ + 'transactionId INTEGER PRIMARY KEY, userId TEXT NOT NULL, provenTxId INTEGER, txid VARCHAR(64)', + 'transactionId INTEGER PRIMARY KEY, userId INTEGER, provenTxId INTEGER, txid VARCHAR(64)', + 'transactionId INTEGER PRIMARY KEY, userId INTEGER NOT NULL, txid VARCHAR(64)', + 'transactionId INTEGER PRIMARY KEY, userId INTEGER NOT NULL, provenTxId INTEGER, txid TEXT', + 'transactionId INTEGER PRIMARY KEY, userId INTEGER NOT NULL, provenTxId INTEGER, txid VARCHAR(64) NOT NULL', + 'transactionId INTEGER PRIMARY KEY, userId INTEGER NOT NULL, provenTxId INTEGER GENERATED ALWAYS AS (userId) VIRTUAL, txid VARCHAR(64)' +])('unsupported SQLite source column refuses before auxiliary DDL: %s', async columns => { + const k = await fixture() + await k.schema.dropTable('transactions') + await k.raw(`CREATE TABLE transactions(${columns})`) + await expect(install(k)).rejects.toThrow('Unsupported snapshot global source column') + expect(await k.schema.hasTable('snapshot_global_guards')).toBe(false) +}) + +test.each(['(userId)', '(txid DESC)', '(txid COLLATE NOCASE)', '(txid) WHERE userId = 1'])( + 'an incomplete or differently ordered source lookup is refused: %s', + async definition => { + const k = await fixture() + await k.schema.dropTable('transactions') + await k.raw( + 'CREATE TABLE transactions(transactionId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,provenTxId INTEGER,txid VARCHAR(64))' + ) + await k.raw(`CREATE INDEX synthetic_txid_lookup ON transactions${definition}`) + await expect(install(k)).rejects.toThrow('requires complete transaction lookup indexes') + expect(await k.schema.hasTable('snapshot_global_guards')).toBe(false) + } +) + +test('SQLite source txid comparison metadata must agree between transactions and requests', async () => { + const k = await fixture() + await k.schema.dropTable('transactions') + await k.raw( + 'CREATE TABLE transactions(transactionId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,provenTxId INTEGER,txid VARCHAR(64) COLLATE NOCASE)' + ) + await k.raw('CREATE INDEX synthetic_txid_lookup ON transactions(txid)') + await expect(install(k)).rejects.toThrow('require matching text definitions') + expect(await k.schema.hasTable('snapshot_global_guards')).toBe(false) +}) + +test.each([ + '(tableId,userId,present,rowId DESC)', + '(tableId,userId,present,rowId COLLATE NOCASE)', + '(tableId,userId,present)', + '(tableId,present,userId,rowId)', + '(tableId,userId,present,rowId) WHERE refs > 0' +])('altered auxiliary page order refuses resume, adoption and down: %s', async definition => { + const k = await fixture() + await install(k) + await journal(k) + await k.raw('DROP INDEX snapshot_global_page') + await k.raw(`CREATE INDEX snapshot_global_page ON snapshot_global_keys${definition}`) + await expect(install(k)).rejects.toThrow('table definition mismatch') + await expect(enabled(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') + expect(await k.schema.hasTable('snapshot_global_keys')).toBe(true) +}) + +test.each([{ afterRowId: -1 }, { afterRowId: 1.5 }, { complete: 2 }, { id: 1 }])( + 'malformed bootstrap progress %j refuses resume and journal adoption', + async changed => { + const k = await fixture() + await install(k) + await journal(k) + await k('snapshot_global_index_progress').where('id', 0).update(changed) + await expect(enabled(k)).rejects.toThrow('incomplete') + if (!('id' in changed)) await expect(install(k)).rejects.toThrow('Invalid snapshot global bootstrap position') + } +) + +test('qualified migration journals and empty progress refuse or adopt the same schema', async () => { + const k = await fixture() + expect(await enabled(k, { schemaName: 'main' })).toBe(false) + await install(k) + await journal(k) + expect(await enabled(k, { schemaName: 'main' })).toBe(true) + await k('snapshot_global_index_progress').delete() + await expect(enabled(k, { schemaName: 'main' })).rejects.toThrow('incomplete') +}) + +test('bootstrap preserves missing request and missing proof distinctions', async () => { + const k = await fixture() + await k('proven_tx_reqs').insert({ provenTxReqId: 5, txid: 'a', provenTxId: null }) + await k('transactions').insert([ + { transactionId: 1, userId: 1, txid: 'missing', provenTxId: null }, + { transactionId: 2, userId: 1, txid: 'missing', provenTxId: 7 }, + { transactionId: 3, userId: 1, txid: 'a', provenTxId: null } + ]) + await install(k) + await expectGlobalMembership(k) + expect(await k('snapshot_global_keys').orderBy('tableId')).toEqual([ + { tableId: 0, userId: 1, rowId: 5, refs: 1, present: 1 }, + { tableId: 1, userId: 1, rowId: 7, refs: 1, present: 0 } + ]) +}) + +test('unexpected unique auxiliary constraints refuse adoption before they can reject valid source writes', async () => { + const k = await fixture() + await install(k) + await journal(k) + await k.raw('CREATE UNIQUE INDEX synthetic_extra_unique ON snapshot_global_keys(rowId)') + await expect(install(k)).rejects.toThrow('table definition mismatch') + await expect(enabled(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') +}) + +test('inconsistent SQLite primary-index metadata refuses adoption instead of assuming its key exists', async () => { + const k = await fixture() + await install(k) + const raw = k.client.raw.bind(k.client) + jest.spyOn(k.client, 'raw').mockImplementation((...args: Parameters) => { + const query = raw(...args) + if (args[0] === 'PRAGMA index_list(??)' && Array.isArray(args[1]) && args[1][0] === 'snapshot_global_keys') { + return query.then((rows: Array<{ origin: string }>) => + rows.filter(row => row.origin !== 'pk') + ) as unknown as Knex.Raw + } + return query + }) + await expect(install(k)).rejects.toThrow('table definition mismatch') +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts index 8b477e533..e826379ba 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts @@ -5,7 +5,7 @@ import { knex } from 'knex' import { StorageKnex } from '../StorageKnex' import { StorageProvider } from '../StorageProvider' import { KnexMigrations } from '../schema/KnexMigrations' -import { SNAPSHOT_CERTIFICATE_INDEX_MIGRATION } from '../schema/snapshotCertificateIndexMigration' +import { SNAPSHOT_GLOBAL_INDEX_MIGRATION } from '../schema/snapshotGlobalIndexMigration' import { readSnapshotProfileIndexState, SNAPSHOT_PROFILE_INDEX_MIGRATION @@ -65,7 +65,7 @@ test('registered profile bootstrap survives reopening and publishes its journal database = knex(options) source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: database }) await expect(source.migrate('profile migration', 'synthetic-profile-migration')).resolves.toBe( - SNAPSHOT_CERTIFICATE_INDEX_MIGRATION + SNAPSHOT_GLOBAL_INDEX_MIGRATION ) expect(await readSnapshotProfileIndexState(database)).toBe(true) expect(await database('snapshot_profile_keys').where('snapshotTableId', 3).count({ count: '*' }).first()).toEqual({ diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index 5944cb5c6..38afb9bde 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' -import { SNAPSHOT_CERTIFICATE_INDEX_MIGRATION } from '../../schema/KnexMigrations' +import { SNAPSHOT_GLOBAL_INDEX_MIGRATION } from '../../schema/KnexMigrations' import { decodeSyncTransfer } from '../../remoting/SyncTransfer' import * as Transfer from '../../remoting/SyncTransfer' import * as ArchiveSource from './KnexSnapshotArchiveSource' @@ -72,7 +72,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { onProgress: p => progress.push(p) }) - expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_CERTIFICATE_INDEX_MIGRATION) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_GLOBAL_INDEX_MIGRATION) expect(manifest.binding.sourceStorage.storageName).toBe('original source') expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) @@ -84,7 +84,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof sourceStorageIdentityKey: 'original-source', archiveId: manifest.archiveId, digest: manifest.digest, - sourceSchema: SNAPSHOT_CERTIFICATE_INDEX_MIGRATION + sourceSchema: SNAPSHOT_GLOBAL_INDEX_MIGRATION }) expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} @@ -153,7 +153,7 @@ test('source schema, primary history and closure stay pinned while an independen await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) - expect(source.sourceSchema).toBe(SNAPSHOT_CERTIFICATE_INDEX_MIGRATION) + expect(source.sourceSchema).toBe(SNAPSHOT_GLOBAL_INDEX_MIGRATION) expect(source.user.activeStorage).toBe(originalPrimary) await expect(source.validateClosure()).resolves.toBeUndefined() expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts index 7b7242534..972cc3006 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts @@ -129,7 +129,8 @@ export async function assertKnexSnapshotArchiveClosure( userId: number, profileIndexes = false, relationIndexes = false, - certificateIndexes = false + certificateIndexes = false, + globalIndexes = false ): Promise { if (!Number.isSafeInteger(userId) || userId < 1) throw new WERR_INVALID_PARAMETER('userId', 'a positive safe ID') await runInSeries(references, async reference => { @@ -144,7 +145,8 @@ export async function assertKnexSnapshotArchiveClosure( userId, profileIndexes, relationIndexes, - certificateIndexes + certificateIndexes, + globalIndexes ) .select(k.raw('1 AS invalid')) .whereNotExists(target) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index d43572604..b6072a9c5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -1,3 +1,4 @@ +import { readSnapshotGlobalIndexState } from '../../schema/snapshotGlobalIndexMigration' import { readSnapshotCertificateIndexState } from '../../schema/snapshotCertificateIndexMigration' import { readSnapshotRelationIndexState } from '../../schema/snapshotRelationIndexMigration' import { readSnapshotProfileIndexState } from '../../schema/snapshotProfileIndexMigration' @@ -53,24 +54,30 @@ export async function openKnexSnapshotArchiveSource( } const view = await openView() try { - const { header, profileIndexes, relationIndexes, certificateIndexes } = await view.read(async trx => { - const sourceStorage = await storage.readSettings(trx) - const user = await storage.findUserByIdentityKey(identityKey, trx) - if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') - return { - header: { - sourceStorage, - user, - sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) - }, - profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - relationIndexes: await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - certificateIndexes: await readSnapshotCertificateIndexState( - storage.toDb(trx), - storage.knex.client.config.migrations - ) + const { header, profileIndexes, relationIndexes, certificateIndexes, globalIndexes } = await view.read( + async trx => { + const sourceStorage = await storage.readSettings(trx) + const user = await storage.findUserByIdentityKey(identityKey, trx) + if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') + return { + header: { + sourceStorage, + user, + sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) + }, + profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), + relationIndexes: await readSnapshotRelationIndexState( + storage.toDb(trx), + storage.knex.client.config.migrations + ), + globalIndexes: await readSnapshotGlobalIndexState(storage.toDb(trx), storage.knex.client.config.migrations), + certificateIndexes: await readSnapshotCertificateIndexState( + storage.toDb(trx), + storage.knex.client.config.migrations + ) + } } - }) + ) const userId = header.user.userId const snapshotId = Utils.toHex(Random(32)) return { @@ -90,7 +97,8 @@ export async function openKnexSnapshotArchiveSource( view, profileIndexes, relationIndexes, - certificateIndexes + certificateIndexes, + globalIndexes ), validateClosure: async () => { await view.read(trx => @@ -99,7 +107,8 @@ export async function openKnexSnapshotArchiveSource( userId, profileIndexes, relationIndexes, - certificateIndexes + certificateIndexes, + globalIndexes ) ) } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index 9e843ffac..2f966f674 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -701,7 +701,7 @@ test('only acknowledged sequence positions are readable, even if an unacknowledg test('the auxiliary migration is registered after the durable sync schema', async () => { const migrations = new KnexMigrations('test', 'source', 'source', 1024) - expect(await migrations.getLatestMigration()).toBe('2026-10-01-005 add snapshot certificate field key indexes') + expect(await migrations.getLatestMigration()).toBe('2026-10-01-006 add snapshot global reference indexes') }) test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts index b9d25826c..612c2002d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts @@ -33,7 +33,7 @@ test.each([StorageClient, StorageMobile])( expect(storage.getSettings()).not.toHaveProperty('snapshotArchive') let transport = (await client.getSnapshotArchiveTransport(identityKey))! const offer = await transport.offer() - expect(offer.sourceSchema).toBe('2026-10-01-005 add snapshot certificate field key indexes') + expect(offer.sourceSchema).toBe('2026-10-01-006 add snapshot global reference indexes') expect(Math.abs(offer.serverTime - Date.now())).toBeLessThan(5000) const fields = { version: 1 as const, diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs index 9e8dbf004..8dfba7ef6 100644 --- a/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotArchiveMysql.cjs @@ -6,6 +6,7 @@ const { join } = require('node:path') const assert = require('node:assert/strict') const { executable, context, image, validateContext, validateContainer } = require('./snapshotArchiveDocker.cjs') const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { mysqlFixtureGroups } = require('./snapshotMysqlFixtureGroups.cjs') const execute = (file, args, options) => new Promise((resolve, reject) => { execFile( @@ -97,18 +98,23 @@ async function main() { await new Promise(resolve => setTimeout(resolve, 500)) } }) - const result = await execute(process.execPath, [join(__dirname, 'snapshotArchiveMysql.cjs')], { - env: { - ...process.env, - TS_STACK_SNAPSHOT_CONTAINER: name, - TS_STACK_SNAPSHOT_CONTAINER_ID: id, - TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, - TS_STACK_SNAPSHOT_MYSQL_SECRET: secret - }, - signal: cancellation.signal, - timeout: 60000 + await runInSeries(mysqlFixtureGroups, async group => { + const started = Date.now() + process.stdout.write(JSON.stringify({ group, status: 'started' }) + '\n') + const result = await execute(process.execPath, [join(__dirname, 'snapshotArchiveMysql.cjs'), group], { + env: { + ...process.env, + TS_STACK_SNAPSHOT_CONTAINER: name, + TS_STACK_SNAPSHOT_CONTAINER_ID: id, + TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, + TS_STACK_SNAPSHOT_MYSQL_SECRET: secret + }, + signal: cancellation.signal, + timeout: 60000 + }) + process.stdout.write(result) + process.stdout.write(JSON.stringify({ group, status: 'passed', milliseconds: Date.now() - started }) + '\n') }) - process.stdout.write(result) } catch (error) { failure = error } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs index 450800ab5..41de44aa3 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs @@ -167,6 +167,8 @@ async function main() { console.log(JSON.stringify({ profileIndexProcessLoss: await qualifySQLiteProfileIndexProcessLoss() })) const { qualifySQLiteRelationIndexProcessLoss } = require('./snapshotRelationIndexCrash.cjs') console.log(JSON.stringify({ relationIndexProcessLoss: await qualifySQLiteRelationIndexProcessLoss() })) + const { qualifySQLiteGlobalIndexProcessLoss } = require('./snapshotGlobalIndexCrash.cjs') + console.log(JSON.stringify({ globalIndexProcessLoss: await qualifySQLiteGlobalIndexProcessLoss() })) const { qualifySQLiteCertificateIndexProcessLoss } = require('./snapshotCertificateIndexCrash.cjs') console.log(JSON.stringify({ certificateIndexProcessLoss: await qualifySQLiteCertificateIndexProcessLoss() })) } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index 119621acf..d723b974d 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -145,7 +145,7 @@ async function captureFixture() { assert.equal(manifest.pages, 14) assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') assert.equal(manifest.binding.user.activeStorage, 'historical selection') - assert.equal(manifest.binding.sourceSchema, '2026-10-01-005 add snapshot certificate field key indexes') + assert.equal(manifest.binding.sourceSchema, '2026-10-01-006 add snapshot global reference indexes') const store = new KnexSnapshotArchiveStore(writer.knex) const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) @@ -433,7 +433,7 @@ async function requestFixture(writer, reader) { sourceStorageIdentityKey: 'native-source', digest: ready.digest }) - assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-005 add snapshot certificate field key indexes') + assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-006 add snapshot global reference indexes') const page = await replacement.read(identity, ready.archiveId, 8) const decoded = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[8])) assert.equal(decoded.rows[0].label, 'replacement') @@ -483,9 +483,73 @@ async function requestFixture(writer, reader) { await Promise.all([controller.close(), replacement.close()]) } } +const { mysqlFixtureGroups } = require('./snapshotMysqlFixtureGroups.cjs') +const indexFixtures = { + profile: async () => { + const { qualifyMysqlProfileIndexProcessLoss } = require('./snapshotProfileIndexCrash.cjs') + const profileIndexProcessLoss = await qualifyMysqlProfileIndexProcessLoss(database, connection) + const { qualifyMysqlProfileIndexLocks } = require('./snapshotProfileIndexMysql.cjs') + const profileIndexLocks = await qualifyMysqlProfileIndexLocks(database, connection) + return { profileIndexProcessLoss, profileIndexLocks } + }, + relation: async () => { + const { qualifyMysqlRelationIndexProcessLoss } = require('./snapshotRelationIndexCrash.cjs') + const relationIndexProcessLoss = await qualifyMysqlRelationIndexProcessLoss(database, connection) + const { qualifyMysqlRelationIndexLocks } = require('./snapshotRelationIndexMysql.cjs') + const relationIndexLocks = await qualifyMysqlRelationIndexLocks(database, connection) + const { qualifyMysqlRelationIndexSeeks } = require('./snapshotRelationIndexSeeks.cjs') + const relationIndexSeeks = await qualifyMysqlRelationIndexSeeks(database, connection) + return { relationIndexProcessLoss, relationIndexLocks, relationIndexSeeks } + }, + certificate: async () => { + const { qualifyMysqlCertificateIndexProcessLoss } = require('./snapshotCertificateIndexCrash.cjs') + const certificateIndexProcessLoss = await qualifyMysqlCertificateIndexProcessLoss(database, connection) + const { qualifyMysqlCertificateIndexLocks } = require('./snapshotCertificateIndexMysql.cjs') + const certificateIndexLocks = await qualifyMysqlCertificateIndexLocks(database, connection) + const { qualifyMysqlCertificateIndexSchedules } = require('./snapshotCertificateIndexMysqlSchedules.cjs') + const certificateIndexSchedules = await qualifyMysqlCertificateIndexSchedules(database, connection) + const { qualifyMysqlCertificateIndexSeeks } = require('./snapshotCertificateIndexSeeks.cjs') + const certificateIndexSeeks = await qualifyMysqlCertificateIndexSeeks(database, connection) + return { certificateIndexProcessLoss, certificateIndexLocks, certificateIndexSchedules, certificateIndexSeeks } + }, + 'global-crash': async () => { + const { qualifyMysqlGlobalIndexProcessLoss } = require('./snapshotGlobalIndexCrash.cjs') + return { globalIndexProcessLoss: await qualifyMysqlGlobalIndexProcessLoss(database, connection) } + }, + 'global-locks': async () => { + const { qualifyMysqlGlobalIndexLocks } = require('./snapshotGlobalIndexMysql.cjs') + return { globalIndexLocks: await qualifyMysqlGlobalIndexLocks(database, connection) } + }, + 'global-schedules': async () => { + const { qualifyMysqlGlobalIndexSchedules } = require('./snapshotGlobalIndexMysqlSchedules.cjs') + return { globalIndexSchedules: await qualifyMysqlGlobalIndexSchedules(database, connection) } + }, + 'global-seeks': async () => { + const { qualifyMysqlGlobalIndexSeeks } = require('./snapshotGlobalIndexSeeks.cjs') + return { globalIndexSeeks: await qualifyMysqlGlobalIndexSeeks(database, connection) } + }, + 'global-integration': async () => { + const { qualifyMysqlGlobalIndexIntegration } = require('./snapshotGlobalIndexIntegration.cjs') + return { globalIndexIntegration: await qualifyMysqlGlobalIndexIntegration(database, connection) } + } +} +async function runIndexFixtures(group) { + const results = {} + const groups = group === 'all' ? mysqlFixtureGroups.slice(1) : [group] + await runInSeries(groups, async name => { + Object.assign(results, await indexFixtures[name]()) + }) + return results +} async function main() { + const group = process.argv[2] ?? 'all' try { + assert.ok(group === 'all' || mysqlFixtureGroups.includes(group), 'Unknown native fixture group') const version = (await database.raw('SELECT VERSION() AS version'))[0][0].version + if (group !== 'archive' && group !== 'all') { + console.log(JSON.stringify({ version, group, ...(await runIndexFixtures(group)) })) + return + } await addSnapshotArchiveTables(database) const store = new KnexSnapshotArchiveStore(database), peer = new KnexSnapshotArchiveStore(replica), @@ -534,24 +598,7 @@ async function main() { assert.equal(await database.schema.hasTable('snapshot_archive_pages'), true) assert.equal(Number((await database('snapshot_archive_capacity').first()).archives), 0) const capture = await captureFixture() - const { qualifyMysqlProfileIndexProcessLoss } = require('./snapshotProfileIndexCrash.cjs') - const profileIndexProcessLoss = await qualifyMysqlProfileIndexProcessLoss(database, connection) - const { qualifyMysqlProfileIndexLocks } = require('./snapshotProfileIndexMysql.cjs') - const profileIndexLocks = await qualifyMysqlProfileIndexLocks(database, connection) - const { qualifyMysqlRelationIndexProcessLoss } = require('./snapshotRelationIndexCrash.cjs') - const relationIndexProcessLoss = await qualifyMysqlRelationIndexProcessLoss(database, connection) - const { qualifyMysqlRelationIndexLocks } = require('./snapshotRelationIndexMysql.cjs') - const relationIndexLocks = await qualifyMysqlRelationIndexLocks(database, connection) - const { qualifyMysqlRelationIndexSeeks } = require('./snapshotRelationIndexSeeks.cjs') - const relationIndexSeeks = await qualifyMysqlRelationIndexSeeks(database, connection) - const { qualifyMysqlCertificateIndexProcessLoss } = require('./snapshotCertificateIndexCrash.cjs') - const certificateIndexProcessLoss = await qualifyMysqlCertificateIndexProcessLoss(database, connection) - const { qualifyMysqlCertificateIndexLocks } = require('./snapshotCertificateIndexMysql.cjs') - const certificateIndexLocks = await qualifyMysqlCertificateIndexLocks(database, connection) - const { qualifyMysqlCertificateIndexSchedules } = require('./snapshotCertificateIndexMysqlSchedules.cjs') - const certificateIndexSchedules = await qualifyMysqlCertificateIndexSchedules(database, connection) - const { qualifyMysqlCertificateIndexSeeks } = require('./snapshotCertificateIndexSeeks.cjs') - const certificateIndexSeeks = await qualifyMysqlCertificateIndexSeeks(database, connection) + const indexes = group === 'all' ? await runIndexFixtures(group) : {} console.log( JSON.stringify({ version, @@ -564,15 +611,7 @@ async function main() { expiredPartialUnreadable: true, profileReservationRace: true, idempotentPartialDdl: true, - profileIndexProcessLoss, - profileIndexLocks, - relationIndexProcessLoss, - relationIndexLocks, - relationIndexSeeks, - certificateIndexProcessLoss, - certificateIndexLocks, - certificateIndexSchedules, - certificateIndexSeeks, + ...indexes, capture }) ) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysql.cjs index b4b3e025d..26733ba0a 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysql.cjs @@ -1,3 +1,4 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') // Synthetic current-read and bootstrap-lock qualification on the native MySQL fixture. const assert = require('node:assert/strict') const { randomUUID } = require('node:crypto') @@ -15,11 +16,15 @@ const success = result => { if (!result.ok) throw result.error } async function until(check) { - for (let i = 0; i < 1000; i++) { - const result = await check() - if (result) return result - await new Promise(resolve => setTimeout(resolve, 5)) + let observed + function* pending() { + for (let i = 0; i < 1000 && !observed; i++) yield i } + await runInSeries(pending(), async () => { + observed = await check() + if (!observed) await new Promise(resolve => setTimeout(resolve, 5)) + }) + if (observed) return observed throw new Error('Native lock observation exceeded five seconds') } async function fixture(admin, connection, isolation) { @@ -32,8 +37,9 @@ async function fixture(admin, connection, isolation) { events = [] let transaction try { - for (const client of [k, writer, observer]) + await runInSeries([k, writer, observer], async client => { await client.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation.toUpperCase()) + }) await k.raw( 'CREATE TABLE certificates(certificateId INT UNSIGNED PRIMARY KEY,userId INT UNSIGNED NOT NULL,isDeleted TINYINT NOT NULL DEFAULT 0) ENGINE=InnoDB' ) @@ -222,8 +228,9 @@ async function qualifyMysqlCertificateIndexLocks(control, connection) { assert.equal(connection.host, '127.0.0.1') assert.equal(connection.database, 'ts569_snapshot') const results = [] - for (const isolation of ['read committed', 'repeatable read']) + await runInSeries(['read committed', 'repeatable read'], async isolation => { results.push(await fixture(control, connection, isolation)) + }) return results } module.exports = { qualifyMysqlCertificateIndexLocks } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysqlSchedules.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysqlSchedules.cjs index a52f4b6ca..6877705c9 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysqlSchedules.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexMysqlSchedules.cjs @@ -1,3 +1,4 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') const assert = require('node:assert/strict') const { randomUUID } = require('node:crypto') const { knex } = require('knex') @@ -29,11 +30,12 @@ async function seed(k) { { certificateId: 1, userId: 1 }, { certificateId: 2, userId: 2 } ]) - for (const [i, fieldName] of names.entries()) + await runInSeries(names.entries(), async ([i, fieldName]) => { await k('certificate_fields') .insert({ userId: (i % 2) + 1, fieldName, certificateId: (i % 2) + 1, fieldValue: 'initial' }) .onConflict(['fieldName', 'certificateId']) .merge(['userId', 'fieldValue']) + }) } async function operation(k, op, counts) { counts[op.kind]++ @@ -120,14 +122,14 @@ async function qualify(k, collation) { user: fc.integer({ min: 1, max: 3 }), name: fc.integer({ min: 0, max: names.length - 1 }) }) - const counts = Array(9).fill(0) + const counts = Array.from({ length: 9 }, () => 0) await fc.assert( fc.asyncProperty(fc.array(generated, { minLength: 1, maxLength: 24 }), async schedule => { await seed(k) - for (const op of schedule) { + await runInSeries(schedule, async op => { await operation(k, op, counts) await oracle(k) - } + }) }), { numRuns: 300, seed: 3242026 } ) @@ -153,7 +155,7 @@ async function qualifyMysqlCertificateIndexSchedules(control, connection) { assert.equal(connection.host, '127.0.0.1') assert.equal(connection.database, 'ts569_snapshot') const results = [] - for (const collation of ['utf8mb4_0900_ai_ci', 'utf8mb4_unicode_ci', 'utf8mb4_bin']) { + await runInSeries(['utf8mb4_0900_ai_ci', 'utf8mb4_unicode_ci', 'utf8mb4_bin'], async collation => { const database = 'ts569_certificate_schedules_' + randomUUID().replaceAll('-', '') await control.raw(`CREATE DATABASE ?? CHARACTER SET utf8mb4 COLLATE ${collation}`, [database]) const k = knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) @@ -163,7 +165,7 @@ async function qualifyMysqlCertificateIndexSchedules(control, connection) { await k.destroy() await control.raw('DROP DATABASE ??', [database]) } - } + }) return results } module.exports = { oracle, qualifyMysqlCertificateIndexSchedules } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexCrash.cjs new file mode 100644 index 000000000..b46f97013 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexCrash.cjs @@ -0,0 +1,268 @@ +// Synthetic process-loss qualification, invoked by the existing native fixtures. +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { randomUUID } = require('node:crypto') +const fs = require('node:fs/promises') +const { writeFileSync } = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { knex, oracle } = require('./snapshotGlobalIndexFixtures.cjs') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + removeSnapshotGlobalIndexes, + readSnapshotGlobalIndexState, + SNAPSHOT_GLOBAL_INDEX_MIGRATION +} = require('../../out/src/storage/schema/snapshotGlobalIndexMigration.js') + +const phases = [ + 'after-guard-table', + 'after-key-table', + 'after-first-index', + 'partial-observers', + 'partial-producers', + 'before-cursor', + 'after-cursor', + 'after-commit' +] +const migrationName = 'synthetic global crash' +const migrationIdentity = 'synthetic-global-crash' + +const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } +const provider = options => + new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: knex(options) }) + +async function migrate(source) { + await source.migrate(migrationName, migrationIdentity) +} +async function seed(options) { + const source = provider(options) + try { + if (options.client === 'better-sqlite3') await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate(migrationName, migrationIdentity) + await source.makeAvailable() + await removeSnapshotGlobalIndexes(source.knex) + await source.knex('knex_migrations').where('name', SNAPSHOT_GLOBAL_INDEX_MIGRATION).delete() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await runInSeries([7, 9], async provenTxId => { + await source.knex('proven_txs').insert({ + ...dates, + provenTxId, + txid: String(provenTxId).repeat(64), + height: 1, + index: 0, + merklePath: Buffer.from([1]), + rawTx: Buffer.from([1]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + }) + await source.knex('proven_tx_reqs').insert({ + ...dates, + provenTxReqId: 5, + txid: 'a'.repeat(64), + provenTxId: 7, + status: 'unknown', + rawTx: Buffer.from([1]), + history: '{}', + notify: '{}' + }) + await runInSeries([0, 200, 400], async start => { + await source.knex('transactions').insert( + Array.from({ length: 200 }, (_, i) => ({ + ...dates, + transactionId: (start + i + 1) * 2, + userId: user.userId, + txid: 'a'.repeat(64), + provenTxId: (start + i) % 2 ? 7 : null, + status: 'completed', + reference: 'global-crash-' + (start + i), + isOutgoing: true, + satoshis: 0, + description: '' + })) + ) + }) + return { userId: user.userId, otherId: other.userId } + } finally { + await source.destroy() + } +} + +async function child(options, phase, marker) { + assert(phases.includes(phase)) + assert.equal(typeof process.send, 'function', 'Child requires its fixture parent') + const source = provider(options) + let cursorWritten = false + const cursor = query => + query.sql.startsWith('update `snapshot_global_index_progress`') && query.bindings.includes(512) + const park = event => { + writeFileSync(marker, JSON.stringify({ phase, event }), { mode: 0o600 }) + process.kill(process.pid, 'SIGKILL') + } + source.knex.on('query', query => { + if (phase === 'before-cursor' && cursor(query)) park('query') + if (phase === 'after-commit' && cursorWritten && query.sql.toLowerCase().startsWith('begin')) + park('next-transaction') + }) + source.knex.on('query-response', (_result, query) => { + const sql = query.sql.toLowerCase() + if (phase === 'after-guard-table' && sql.startsWith('create table `snapshot_global_guards`')) park('query-response') + if (phase === 'after-key-table' && sql.startsWith('create table `snapshot_global_keys`')) park('query-response') + if ( + phase === 'after-first-index' && + (sql.startsWith('create index `snapshot_global_page`') || + sql.startsWith('alter table `snapshot_global_keys` add index `snapshot_global_page`')) + ) + park('query-response') + if (phase === 'partial-observers' && sql.startsWith('create trigger snapshot_global_edge_delete ')) + park('query-response') + if (phase === 'partial-producers' && sql.startsWith('create trigger snapshot_global_tx_insert ')) + park('query-response') + if (cursor(query)) { + cursorWritten = true + if (phase === 'after-cursor') park('query-response') + } + if (phase === 'after-commit' && cursorWritten && sql.startsWith('commit')) park('query-response') + }) + try { + await migrate(source) + throw new Error('Expected migration boundary was not reached') + } finally { + await source.destroy() + } +} + +async function terminateAt(options, phase) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-global-boundary-')) + const marker = path.join(directory, 'boundary.json') + const processHandle = spawn(process.execPath, [__filename, 'child'], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + let stderr = '' + processHandle.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-65536) + }) + const exited = new Promise((resolve, reject) => { + processHandle.once('exit', (code, signal) => resolve({ code, signal })) + processHandle.once('error', reject) + }) + const timer = setTimeout(() => processHandle.kill('SIGKILL'), 15000) + try { + processHandle.send({ options, phase, marker }) + const result = await exited + assert.equal(result.signal, 'SIGKILL', stderr) + const observed = JSON.parse(await fs.readFile(marker, 'utf8')) + assert.equal(observed.phase, phase) + return { phase, event: observed.event, signal: result.signal } + } finally { + clearTimeout(timer) + if (processHandle.exitCode === null && processHandle.signalCode === null) { + processHandle.kill('SIGKILL') + await exited + } + await fs.rm(directory, { recursive: true, force: true }) + } +} + +async function qualify(options, phase) { + const { userId, otherId } = await seed(options) + const outcome = await terminateAt(options, phase) + const source = provider(options) + const database = source.knex + try { + assert.equal(await readSnapshotGlobalIndexState(database), false) + if (['before-cursor', 'after-cursor', 'after-commit'].includes(phase)) { + const committed = phase === 'after-commit' + const progress = await database('snapshot_global_index_progress').where('id', 0).first() + assert.equal(progress.afterRowId, committed ? 512 : 0) + assert.equal( + Number((await database('snapshot_global_edges').count({ count: '*' }).first()).count), + committed ? 640 : 0 + ) + } + await database('transactions').where('transactionId', 2).update({ userId: otherId }) + await database('transactions').where('transactionId', 4).delete() + await database('transactions').insert({ + ...dates, + transactionId: 3, + userId, + txid: 'a'.repeat(64), + provenTxId: null, + status: 'completed', + reference: 'behind-cursor', + isOutgoing: true, + satoshis: 0, + description: '' + }) + await database('proven_tx_reqs').where('provenTxReqId', 5).update({ provenTxId: 9 }) + // A killed migrator leaves Knex's lock claimed. This is fixture-owned recovery; + // the verified child is gone and no other migrator can own this isolated store. + await database.migrate.forceFreeMigrationsLock() + await migrate(source) + assert.equal(await readSnapshotGlobalIndexState(database), true) + await oracle(database) + assert.equal( + Number( + (await database('knex_migrations').where('name', SNAPSHOT_GLOBAL_INDEX_MIGRATION).count({ count: '*' }).first()) + .count + ), + 1 + ) + return { ...outcome, journalPublishedAfterRecovery: true, independentProfileChangeAndLowIdInsert: true } + } finally { + await source.destroy() + } +} + +async function qualifySQLiteGlobalIndexProcessLoss() { + const results = [] + await runInSeries(phases, async phase => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-global-index-crash-')) + try { + results.push( + await qualify( + { + client: 'better-sqlite3', + connection: { filename: path.join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }, + phase + ) + ) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + }) + return results +} + +async function qualifyMysqlGlobalIndexProcessLoss(control, connection) { + // The calling fixture has already verified its disposable, pinned container. + const results = [] + await runInSeries(phases, async phase => { + const database = 'ts569_global_' + randomUUID().replaceAll('-', '') + await control.raw('CREATE DATABASE ??', [database]) + try { + results.push( + await qualify({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }, phase) + ) + } finally { + await control.raw('DROP DATABASE ??', [database]) + } + }) + return results +} + +if (process.argv[2] === 'child') { + assert.equal(typeof process.send, 'function', 'Child execution requires its fixture parent') + process.once('message', ({ options, phase, marker }) => { + child(options, phase, marker).catch(error => { + console.error(error) + process.exitCode = 1 + process.disconnect() + }) + }) +} +module.exports = { qualifySQLiteGlobalIndexProcessLoss, qualifyMysqlGlobalIndexProcessLoss } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexFixtures.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexFixtures.cjs new file mode 100644 index 000000000..c8b75efec --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexFixtures.cjs @@ -0,0 +1,171 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const assert = require('node:assert/strict') +const { knex } = require('knex') +const fc = require('fast-check') +const edges = 'snapshot_global_edges', + keys = 'snapshot_global_keys', + guards = 'snapshot_global_guards' +async function sourceSchema(k, collation) { + const mysql = k.client.config.client === 'mysql2' + const integer = mysql ? 'INT UNSIGNED' : 'INTEGER' + const suffix = mysql ? ' ENGINE=InnoDB' : '' + const text = `VARCHAR(64) COLLATE ${collation}` + await k.raw(`CREATE TABLE proven_txs(provenTxId ${integer} NOT NULL PRIMARY KEY)${suffix}`) + await k.raw( + `CREATE TABLE proven_tx_reqs(provenTxReqId ${integer} NOT NULL PRIMARY KEY,provenTxId ${integer},txid ${text} NOT NULL UNIQUE)${suffix}` + ) + await k.raw( + `CREATE TABLE transactions(transactionId ${integer} NOT NULL PRIMARY KEY,userId ${integer} NOT NULL,provenTxId ${integer},txid ${text})${suffix}` + ) + await k.schema.alterTable('transactions', t => t.index('txid')) +} +async function oracle(k) { + const direct = k('transactions') + .select(k.raw('transactionId,0 AS requestId,1 AS tableId,provenTxId AS rowId,userId')) + .whereNotNull('provenTxId') + const req = k('transactions AS t') + .join('proven_tx_reqs AS r', 'r.txid', 't.txid') + .select(k.raw('t.transactionId,r.provenTxReqId AS requestId,0 AS tableId,r.provenTxReqId AS rowId,t.userId')) + const indirect = k('transactions AS t') + .join('proven_tx_reqs AS r', 'r.txid', 't.txid') + .select(k.raw('t.transactionId,r.provenTxReqId AS requestId,1 AS tableId,r.provenTxId AS rowId,t.userId')) + .whereNotNull('r.provenTxId') + const expected = [...(await direct), ...(await req), ...(await indirect)] + const order = rows => + rows + .map(row => [row.transactionId, row.requestId, row.tableId, row.rowId, row.userId]) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + assert.deepEqual(order(await k(edges)), order(expected), 'Independent relational union must equal every edge') + const proofs = new Set((await k('proven_txs')).map(row => row.provenTxId)) + const counts = new Map() + for (const row of expected) { + const key = JSON.stringify([row.tableId, row.userId, row.rowId]) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + const actual = await k(keys) + assert.equal(actual.length, counts.size) + for (const row of actual) { + const key = JSON.stringify([row.tableId, row.userId, row.rowId]) + assert.equal(row.refs, counts.get(key), key) + assert.equal(Number(row.present), row.tableId === 0 || proofs.has(row.rowId) ? 1 : 0, key) + } + const expectedGuards = [...new Set(expected.filter(row => row.tableId === 1).map(row => row.rowId))].sort( + (a, b) => a - b + ) + const actualGuards = await k(guards).orderBy('proofId') + assert.deepEqual( + actualGuards.map(row => row.proofId), + expectedGuards, + 'No unused proof serialization rows' + ) + for (const row of actualGuards) assert.equal(Number(row.present), proofs.has(row.proofId) ? 1 : 0) + return { edges: expected.length, keys: counts.size } +} +async function sequential(k) { + const records = [] + const step = async (name, action) => { + await action() + records.push({ name, ...(await oracle(k)) }) + } + await step('proof', () => k('proven_txs').insert({ provenTxId: 7 })) + await step('direct owner', () => k('transactions').insert({ transactionId: 1, userId: 1, txid: 'a', provenTxId: 7 })) + await step('duplicate owner', () => + k('transactions').insert({ transactionId: 2, userId: 1, txid: 'a', provenTxId: 7 }) + ) + await step('other profile', () => k('transactions').insert({ transactionId: 3, userId: 2, txid: 'a', provenTxId: 7 })) + await step('same proof through request', () => + k('proven_tx_reqs').insert({ provenTxReqId: 5, txid: 'a', provenTxId: 7 }) + ) + await step('one reference removed', () => k('transactions').where('transactionId', 1).delete()) + await step('proof absent with references', () => k('proven_txs').where('provenTxId', 7).delete()) + await step('proof returned', () => k('proven_txs').insert({ provenTxId: 7 })) + await step('new absent indirect proof', () => k('proven_tx_reqs').where('provenTxReqId', 5).update({ provenTxId: 9 })) + await step('new proof appears', () => k('proven_txs').insert({ provenTxId: 9 })) + await step('transaction moves profile', () => k('transactions').where('transactionId', 2).update({ userId: 3 })) + await step('request changes txid', () => k('proven_tx_reqs').where('provenTxReqId', 5).update({ txid: 'b' })) + await step('transaction matches new txid', () => k('transactions').where('transactionId', 3).update({ txid: 'b' })) + await step('request changes ID', () => k('proven_tx_reqs').where('provenTxReqId', 5).update({ provenTxReqId: 6 })) + await step('transaction changes ID', () => k('transactions').where('transactionId', 2).update({ transactionId: 20 })) + await step('proof changes ID', () => k('proven_txs').where('provenTxId', 9).update({ provenTxId: 10 })) + await step('request removed', () => k('proven_tx_reqs').where('provenTxReqId', 6).delete()) + await step('last direct references removed', () => k('transactions').delete()) + return records +} +async function generated(k) { + let schedules = 0, + steps = 0, + expectedConstraintFailures = 0 + const op = fc.record({ + kind: fc.integer({ min: 0, max: 8 }), + id: fc.integer({ min: 1, max: 5 }), + userId: fc.integer({ min: 1, max: 3 }), + otherId: fc.integer({ min: 1, max: 5 }), + nullable: fc.boolean() + }) + await fc.assert( + fc.asyncProperty(fc.array(op, { minLength: 1, maxLength: 24 }), async ops => { + await k('transactions').delete() + await k('proven_tx_reqs').delete() + await k('proven_txs').delete() + await oracle(k) + await runInSeries(ops, async o => { + const proof = o.nullable ? null : o.otherId, + txid = o.nullable ? null : 'r' + o.otherId + try { + switch (o.kind) { + case 0: + await k('transactions') + .insert({ transactionId: o.id, userId: o.userId, txid, provenTxId: proof }) + .onConflict('transactionId') + .merge() + break + case 1: + await k('transactions').where('transactionId', o.id).delete() + break + case 2: + await k('transactions').where('transactionId', o.id).update({ userId: o.userId, txid, provenTxId: proof }) + break + case 3: + await k('proven_tx_reqs') + .insert({ provenTxReqId: o.id, txid: 'r' + o.id, provenTxId: proof }) + .onConflict('provenTxReqId') + .merge() + break + case 4: + await k('proven_tx_reqs').where('provenTxReqId', o.id).delete() + break + case 5: + await k('proven_tx_reqs') + .where('provenTxReqId', o.id) + .update({ txid: 'r' + o.otherId, provenTxId: proof }) + break + case 6: + await k('proven_txs').insert({ provenTxId: o.id }).onConflict('provenTxId').ignore() + break + case 7: + await k('proven_txs').where('provenTxId', o.id).delete() + break + case 8: + await k('proven_txs').where('provenTxId', o.id).update({ provenTxId: o.otherId }) + break + } + } catch (error) { + if ( + error.code === 'ER_DUP_ENTRY' || + error.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || + error.code === 'SQLITE_CONSTRAINT_UNIQUE' + ) + expectedConstraintFailures++ + else throw error + } + await oracle(k) + steps++ + }) + schedules++ + }), + { numRuns: 300, seed: 3242026, endOnFailure: true } + ) + assert.equal(schedules, 300) + return { schedules, steps, expectedConstraintFailures, seed: 3242026 } +} +module.exports = { oracle, sequential, sourceSchema, generated, knex } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexIntegration.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexIntegration.cjs new file mode 100644 index 000000000..b8ca4eba0 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexIntegration.cjs @@ -0,0 +1,320 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex, oracle, sourceSchema } = require('./snapshotGlobalIndexFixtures.cjs') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const helper = require('../../out/src/storage/schema/snapshotGlobalIndexMigration.js') +const { + createKnexWalletSnapshotPageReader: pageReader, + openKnexWalletReadSnapshot +} = require('../../out/src/storage/snapshot/KnexWalletReadSnapshot.js') +const { openKnexSnapshotArchiveSource } = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveSource.js') +const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } +const identity = '02' + '11'.repeat(32) +const standardKeys = { proven_txs: 'provenTxId', proven_tx_reqs: 'provenTxReqId', transactions: 'transactionId' } +async function compareReaders(source, users) { + const tables = [ + 'provenTxs', + 'provenTxReqs', + 'outputBaskets', + 'transactions', + 'commissions', + 'outputs', + 'outputTags', + 'outputTagMaps', + 'txLabels', + 'txLabelMaps', + 'certificates', + 'certificateFields', + 'syncStates' + ] + const results = [] + await runInSeries(users, async userId => { + const view = await source.openReadSnapshot() + try { + const legacy = pageReader(source, userId, 'global-draft', view, true, true, true, false) + const indexed = pageReader(source, userId, 'global-draft', view, true, true, true, true) + await runInSeries(tables, async table => { + let cursor, + rows = 0, + pages = 0, + complete = false + function* pendingPages() { + while (!complete) yield undefined + } + await runInSeries(pendingPages(), async () => { + const options = { maxRows: 17, maxBytes: 131072 } + const expected = await legacy(table, cursor, options) + const actual = await indexed(table, cursor, options) + assert.deepEqual(actual, expected, `All source rows, public cursor and byte charges must match for ${table}`) + rows += actual.rows.length + pages++ + assert.ok(pages < 1000) + complete = actual.done + cursor = actual.cursor + }) + results.push({ userId, table, rows, pages }) + }) + } finally { + await view.close() + } + }) + return results +} +async function qualifySchema(options) { + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: knex(options) }) + const k = source.knex + try { + await source.migrate('synthetic global draft', 'synthetic-global-draft') + await source.makeAvailable() + const users = [] + await runInSeries(['02' + '11'.repeat(32), '03' + '22'.repeat(32)], async identity => { + users.push((await source.findOrInsertUser(identity)).user.userId) + }) + await runInSeries([7, 9], async provenTxId => { + await k('proven_txs').insert({ + ...dates, + provenTxId, + txid: String(provenTxId).repeat(64), + height: 1, + index: 0, + merklePath: Buffer.from([1]), + rawTx: Buffer.from([1]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + }) + await runInSeries([5, 6], async provenTxReqId => { + await k('proven_tx_reqs').insert({ + ...dates, + provenTxReqId, + txid: String(provenTxReqId).repeat(64), + provenTxId: 9, + status: 'unknown', + history: '{}', + notify: '{}', + rawTx: Buffer.from([1]) + }) + }) + await runInSeries([0, 1, 2], async batch => { + await k('transactions').insert( + Array.from({ length: 200 }, (_, i) => ({ + ...dates, + transactionId: batch * 200 + i + 1, + userId: users[i % 2], + txid: String(i % 2 ? 5 : 6).repeat(64), + provenTxId: i % 2 ? 7 : null, + status: 'completed', + reference: 'global-' + (batch * 200 + i), + isOutgoing: true, + satoshis: 0, + description: '' + })) + ) + }) + const oldIndexes = {} + await runInSeries(Object.keys(standardKeys), async table => { + oldIndexes[table] = + options.client === 'mysql2' + ? (await k.raw('SHOW INDEXES FROM ??', [table]))[0].map(row => [ + row.Key_name, + row.Seq_in_index, + row.Column_name + ]) + : await k.raw('PRAGMA index_list(??)', [table]) + }) + await helper.addSnapshotGlobalIndexes(k) + const initial = await oracle(k) + const initialReaders = await compareReaders(source, users) + await k('transactions').where('transactionId', 1).update({ userId: users[1], provenTxId: 7 }) + await oracle(k) + await k('proven_tx_reqs').where('provenTxReqId', 5).update({ provenTxId: 7 }) + await oracle(k) + await k('transactions').whereIn('transactionId', [3, 7, 19, 21]).delete() + await oracle(k) + const changedReaders = await compareReaders(source, users) + const standard = {} + await runInSeries(Object.keys(standardKeys), async table => { + standard[table] = await k(table).orderBy(standardKeys[table]) + const now = + options.client === 'mysql2' + ? (await k.raw('SHOW INDEXES FROM ??', [table]))[0].map(row => [ + row.Key_name, + row.Seq_in_index, + row.Column_name + ]) + : await k.raw('PRAGMA index_list(??)', [table]) + assert.deepEqual(now, oldIndexes[table]) + }) + await helper.removeSnapshotGlobalIndexes(k) + await runInSeries(Object.keys(standard), async table => { + assert.deepEqual(await k(table).orderBy(standardKeys[table]), standard[table]) + }) + await helper.addSnapshotGlobalIndexes(k) + const reinstalled = await oracle(k) + return { + realStorageKnexSchema: true, + dialect: options.client, + bootstrapRows: 600, + standardIndexesPreserved: true, + sourceRowsPreservedOnRemoval: true, + initial, + reinstalled, + initialReaders, + changedReaders + } + } finally { + await source.destroy() + } +} +async function qualifyPinned(options, archive) { + const open = archive ? openKnexSnapshotArchiveSource : openKnexWalletReadSnapshot + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: knex(options) }) + const writer = knex(options), + k = source.knex + let before, after + try { + if (options.client === 'better-sqlite3') await k.raw('PRAGMA journal_mode = WAL') + await source.migrate('global pinned draft', 'global-pinned-draft') + await source.makeAvailable() + const { user } = await source.findOrInsertUser(identity) + await runInSeries([7, 9], async provenTxId => { + await k('proven_txs').insert({ + ...dates, + provenTxId, + txid: String(provenTxId).repeat(64), + height: 1, + index: 0, + merklePath: Buffer.from([1]), + rawTx: Buffer.from([1]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + }) + await k('proven_tx_reqs').insert({ + ...dates, + provenTxReqId: 5, + txid: 'a'.repeat(64), + provenTxId: 7, + status: 'unknown', + rawTx: Buffer.from([1]), + history: '{}', + notify: '{}' + }) + await k('transactions').insert({ + ...dates, + transactionId: 1, + userId: user.userId, + txid: 'a'.repeat(64), + provenTxId: 7, + status: 'completed', + reference: 'pinned-global', + isOutgoing: true, + satoshis: 0, + description: '' + }) + const queries = [] + k.on('query', q => { + if (q.sql.includes('cross join')) queries.push(q.sql) + }) + before = await open(source, identity, {}) + await writer.transaction(async trx => { + await trx('transactions').where('transactionId', 1).update({ provenTxId: 9 }) + await trx('proven_tx_reqs').where('provenTxReqId', 5).update({ provenTxId: 9 }) + }) + await oracle(writer) + if (archive) await before.validateClosure() + assert.deepEqual( + (await before.readPage('provenTxs')).rows.map(row => row.provenTxId), + [7] + ) + assert.deepEqual( + (await before.readPage('provenTxReqs')).rows.map(row => row.provenTxId), + [7] + ) + assert.ok(queries.some(sql => sql.includes('snapshot_global_keys'))) + await before.close() + before = undefined + after = await open(source, identity, {}) + assert.deepEqual( + (await after.readPage('provenTxs')).rows.map(row => row.provenTxId), + [9] + ) + assert.deepEqual( + (await after.readPage('provenTxReqs')).rows.map(row => row.provenTxId), + [9] + ) + await writer('transactions').where('transactionId', 1).delete() + assert.deepEqual( + (await after.readPage('provenTxs')).rows.map(row => row.provenTxId), + [9] + ) + await after.close() + after = undefined + after = await open(source, identity, {}) + assert.equal((await after.readPage('provenTxs')).rows.length, 0) + assert.equal((await after.readPage('provenTxReqs')).rows.length, 0) + return { + archive, + dialect: options.client, + automaticJournalAdoption: true, + independentWriterCommittedWhileOpen: true, + retainedOldReference: true, + freshViewNewReference: true, + finalReferenceRemoval: true + } + } finally { + if (before) await before.close() + if (after) await after.close() + await writer.destroy() + await source.destroy() + } +} +async function qualifyCascade(options) { + const k = knex(options) + try { + await sourceSchema(k, 'utf8mb4_0900_ai_ci') + await k.raw( + 'ALTER TABLE transactions ADD CONSTRAINT synthetic_cascade FOREIGN KEY(provenTxId) REFERENCES proven_txs(provenTxId) ON DELETE SET NULL' + ) + await assert.rejects(helper.addSnapshotGlobalIndexes(k), /requires explicit row mutations/) + assert.equal(await k.schema.hasTable('snapshot_global_guards'), false) + await k.raw('ALTER TABLE transactions DROP FOREIGN KEY synthetic_cascade') + await k.raw( + 'ALTER TABLE transactions ADD CONSTRAINT synthetic_restrict FOREIGN KEY(provenTxId) REFERENCES proven_txs(provenTxId) ON DELETE RESTRICT ON UPDATE RESTRICT' + ) + await helper.addSnapshotGlobalIndexes(k) + await k('proven_txs').insert({ provenTxId: 7 }) + await k('transactions').insert({ transactionId: 1, userId: 1, provenTxId: 7, txid: null }) + await oracle(k) + return { unsupportedCascadeRejectedBeforeDDL: true, standardRestrictSupported: true } + } finally { + await k.destroy() + } +} +async function qualifyMysqlGlobalIndexIntegration(admin, connection) { + const results = [] + await runInSeries( + [ + ['schema', qualifySchema], + ['ordinary', options => qualifyPinned(options, false)], + ['archive', options => qualifyPinned(options, true)], + ['cascade', qualifyCascade] + ], + async ([name, qualify]) => { + const database = 'ts569_global_integration_' + randomUUID().replaceAll('-', '') + await admin.raw('CREATE DATABASE ??', [database]) + try { + results.push({ + name, + ...(await qualify({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } })) + }) + } finally { + await admin.raw('DROP DATABASE ??', [database]) + } + } + ) + return results +} +module.exports = { qualifyMysqlGlobalIndexIntegration } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexMysql.cjs new file mode 100644 index 000000000..7ca6da8fc --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexMysql.cjs @@ -0,0 +1,136 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +// Native current-read and exact reference-count concurrency qualification. +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { oracle, knex, sourceSchema } = require('./snapshotGlobalIndexFixtures.cjs') +const { addSnapshotGlobalIndexes } = require('../../out/src/storage/schema/snapshotGlobalIndexMigration.js') +const settle = p => + p.then( + value => ({ ok: true, value }), + error => ({ ok: false, error }) + ) +const success = result => { + if (!result.ok) throw result.error +} +async function cases(control, connection, isolation) { + const database = 'ts569_global_locks_' + randomUUID().replaceAll('-', '') + await control.raw('CREATE DATABASE ??', [database]) + const open = () => knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + const k = open(), + writer = open(), + observer = open(), + results = [] + let trx + try { + await runInSeries([k, writer], async db => { + await db.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation.toUpperCase()) + }) + await sourceSchema(k, 'utf8mb4_0900_ai_ci') + await addSnapshotGlobalIndexes(k) + const requester = Number((await k.raw('SELECT CONNECTION_ID() AS id'))[0][0].id) + const wait = async () => { + let observed + function* pending() { + for (let i = 0; i < 1000 && !observed; i++) yield i + } + await runInSeries(pending(), async () => { + const [rows] = await observer.raw( + 'SELECT l.OBJECT_NAME AS tableName,l.LOCK_MODE AS lockMode FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID AND l.ENGINE=w.ENGINE JOIN performance_schema.threads t ON t.THREAD_ID=l.THREAD_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND t.PROCESSLIST_ID=?', + [requester] + ) + if (rows.length) observed = rows + else await new Promise(resolve => setTimeout(resolve, 5)) + }) + if (observed) return observed + throw new Error('No native lock observed within five seconds') + } + const reset = async () => { + await k('transactions').delete() + await k('proven_tx_reqs').delete() + await k('proven_txs').delete() + await oracle(k) + } + const locked = async (name, held, pending) => { + trx = await writer.transaction() + let operation + try { + await held(trx) + operation = settle(pending()) + const locks = await wait() + await trx.commit() + success(await operation) + results.push({ name, locks, ...(await oracle(k)) }) + } finally { + if (!trx.isCompleted()) await trx.rollback() + if (operation) success(await operation) + } + } + await locked( + 'proof insertion waits for absent-proof membership publication', + t => t('transactions').insert({ transactionId: 1, userId: 1, txid: 'a', provenTxId: 71 }), + () => k('proven_txs').insert({ provenTxId: 71 }) + ) + await reset() + await locked( + 'membership waits for proof insertion', + t => t('proven_txs').insert({ provenTxId: 71 }), + () => k('transactions').insert({ transactionId: 1, userId: 1, txid: 'a', provenTxId: 71 }) + ) + await reset() + await k('transactions').insert({ transactionId: 1, userId: 1, txid: 'a', provenTxId: 71 }) + trx = await writer.transaction() + try { + assert.equal(Number((await trx('snapshot_global_guards').where('proofId', 71).first()).present), 0) + await k('proven_txs').insert({ provenTxId: 71 }) + await trx('transactions').insert({ transactionId: 2, userId: 2, txid: 'b', provenTxId: 71 }) + await trx.commit() + results.push({ name: 'old snapshot cannot publish stale proof presence', ...(await oracle(k)) }) + } finally { + if (!trx.isCompleted()) await trx.rollback() + } + await reset() + await k('proven_txs').insert([{ provenTxId: 71 }, { provenTxId: 72 }]) + await k('proven_tx_reqs').insert({ provenTxReqId: 5, txid: 'a', provenTxId: 71 }) + trx = await writer.transaction() + try { + assert.equal((await trx('proven_tx_reqs').where('provenTxReqId', 5).first()).provenTxId, 71) + await k('proven_tx_reqs').where('provenTxReqId', 5).update({ provenTxId: 72 }) + await trx('transactions').insert({ transactionId: 1, userId: 1, txid: 'a', provenTxId: null }) + await trx.commit() + results.push({ name: 'old snapshot cannot restore old request proof', ...(await oracle(k)) }) + } finally { + if (!trx.isCompleted()) await trx.rollback() + } + await reset() + await k('proven_txs').insert({ provenTxId: 71 }) + await k('transactions').insert([ + { transactionId: 1, userId: 1, txid: 'a', provenTxId: 71 }, + { transactionId: 2, userId: 1, txid: 'b', provenTxId: 71 } + ]) + await locked( + 'concurrent final reference removals preserve exact count', + t => t('transactions').where('transactionId', 1).delete(), + () => k('transactions').where('transactionId', 2).delete() + ) + await reset() + await k('proven_txs').insert({ provenTxId: 71 }) + await locked( + 'request insertion sees pending matching transaction', + t => t('transactions').insert({ transactionId: 1, userId: 1, txid: 'a', provenTxId: null }), + () => k('proven_tx_reqs').insert({ provenTxReqId: 5, txid: 'a', provenTxId: 71 }) + ) + return { isolation, results } + } finally { + if (trx && !trx.isCompleted()) await trx.rollback() + await Promise.all([k.destroy(), writer.destroy(), observer.destroy()]) + await control.raw('DROP DATABASE ??', [database]) + } +} +async function qualifyMysqlGlobalIndexLocks(control, connection) { + const results = [] + await runInSeries(['read committed', 'repeatable read'], async isolation => { + results.push(await cases(control, connection, isolation)) + }) + return results +} +module.exports = { qualifyMysqlGlobalIndexLocks } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexMysqlSchedules.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexMysqlSchedules.cjs new file mode 100644 index 000000000..535e736d9 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexMysqlSchedules.cjs @@ -0,0 +1,102 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex, oracle, sequential, sourceSchema, generated } = require('./snapshotGlobalIndexFixtures.cjs') +const helper = require('../../out/src/storage/schema/snapshotGlobalIndexMigration.js') +async function qualifySchedules(k, collation) { + await sourceSchema(k, collation) + await k('proven_txs').insert([{ provenTxId: 7 }, { provenTxId: 9 }]) + await k('proven_tx_reqs').insert([ + { provenTxReqId: 5, txid: 'a', provenTxId: 9 }, + { provenTxReqId: 6, txid: 'b', provenTxId: 7 } + ]) + await runInSeries([0, 200, 400], async start => { + await k('transactions').insert( + Array.from({ length: 200 }, (_, i) => ({ + transactionId: start + i + 1, + userId: ((start + i) % 3) + 1, + txid: (start + i) % 2 ? 'a' : 'b', + provenTxId: (start + i) % 2 ? 7 : null + })) + ) + }) + assert.equal(await helper.readSnapshotGlobalIndexState(k), false) + await helper.addSnapshotGlobalIndexes(k) + const bootstrap = await oracle(k) + const before = await k('snapshot_global_keys').orderBy(['tableId', 'userId', 'rowId']) + await helper.addSnapshotGlobalIndexes(k) + assert.deepEqual(await k('snapshot_global_keys').orderBy(['tableId', 'userId', 'rowId']), before) + await k('snapshot_global_index_progress').where('id', 0).update({ afterRowId: 0, complete: false }) + await helper.addSnapshotGlobalIndexes(k) + assert.deepEqual(await k('snapshot_global_keys').orderBy(['tableId', 'userId', 'rowId']), before) + assert.equal(await helper.readSnapshotGlobalIndexState(k), false) + await k.schema.createTable('knex_migrations', t => t.string('name')) + await k('knex_migrations').insert({ name: helper.SNAPSHOT_GLOBAL_INDEX_MIGRATION }) + assert.equal(await helper.readSnapshotGlobalIndexState(k), true) + await k('transactions').delete() + await k('proven_tx_reqs').delete() + await k('proven_txs').delete() + const transitions = await sequential(k) + const schedules = await generated(k) + const source = await k('transactions').orderBy('transactionId') + await helper.removeSnapshotGlobalIndexes(k) + assert.deepEqual(await k('transactions').orderBy('transactionId'), source) + await assert.rejects(helper.readSnapshotGlobalIndexState(k), /incomplete/) + await helper.addSnapshotGlobalIndexes(k) + await oracle(k) + assert.equal(await helper.readSnapshotGlobalIndexState(k), true) + return { + dialect: k.client.config.client, + collation, + bootstrap, + bootstrapRows: 600, + repeatedInstall: true, + resumedReplay: true, + journalAdoption: true, + removalPreservesSource: true, + sequential: transitions.length, + ...schedules + } +} +async function qualifyBoundaries(k, collation) { + await sourceSchema(k, collation) + await helper.addSnapshotGlobalIndexes(k) + await k('proven_txs').insert({ provenTxId: 4294967295 }) + await k('proven_tx_reqs').insert({ provenTxReqId: 4294967294, txid: 'A', provenTxId: 4294967295 }) + await k('transactions').insert({ transactionId: 4294967293, userId: 4294967292, txid: 'A', provenTxId: 4294967295 }) + await oracle(k) + await runInSeries(['a', 'a ', 'é', 'e\u0301', 'A\0B', '😀'.repeat(64)], async text => { + await k('proven_tx_reqs').where('provenTxReqId', 4294967294).update({ txid: text }) + await oracle(k) + await k('transactions').where('transactionId', 4294967293).update({ txid: text }) + await oracle(k) + }) + await k('snapshot_global_index_progress').where('id', 0).update({ afterRowId: 0, complete: false }) + await helper.addSnapshotGlobalIndexes(k) + await oracle(k) + return { collation, unsignedIdBoundary: 4294967295, txidTextAndReplay: true } +} +async function qualifyMysqlGlobalIndexSchedules(admin, connection) { + const results = [] + await runInSeries(['utf8mb4_0900_ai_ci', 'utf8mb4_unicode_ci', 'utf8mb4_bin'], async collation => { + await runInSeries( + [ + ['schedules', qualifySchedules], + ['boundaries', qualifyBoundaries] + ], + async ([name, qualify]) => { + const database = 'ts569_global_' + randomUUID().replaceAll('-', '') + await admin.raw(`CREATE DATABASE ?? CHARACTER SET utf8mb4 COLLATE ${collation}`, [database]) + const k = knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + try { + results.push({ name, ...(await qualify(k, collation)) }) + } finally { + await k.destroy() + await admin.raw('DROP DATABASE ??', [database]) + } + } + ) + }) + return results +} +module.exports = { qualifyMysqlGlobalIndexSchedules } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexSeeks.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexSeeks.cjs new file mode 100644 index 000000000..70cbf6ec4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexSeeks.cjs @@ -0,0 +1,163 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { addSnapshotGlobalIndexes } = require('../../out/src/storage/schema/snapshotGlobalIndexMigration.js') +const { + createKnexWalletSnapshotPageReader: pageReader +} = require('../../out/src/storage/snapshot/KnexWalletReadSnapshot.js') +const date = new Date('2026-01-01'), + dates = { created_at: date, updated_at: date } +const txid = id => id.toString(16).padStart(64, '0') +async function seed(source) { + const k = source.knex + const users = [] + await runInSeries(['02' + '11'.repeat(32), '03' + '22'.repeat(32)], async key => { + users.push((await source.findOrInsertUser(key)).user.userId) + }) + await runInSeries( + Array.from({ length: 32 }, (_, i) => i * 256), + async start => { + const ids = Array.from({ length: 256 }, (_, i) => start + i + 1) + await k('proven_txs').insert( + ids.map(id => ({ + ...dates, + provenTxId: id, + txid: txid(id), + height: 1, + index: 0, + merklePath: Buffer.from([1]), + rawTx: Buffer.from([1]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + })) + ) + await k('proven_tx_reqs').insert( + ids.map(id => ({ + ...dates, + provenTxReqId: id, + provenTxId: id, + txid: txid(id), + history: '{}', + notify: '{}', + status: 'unknown', + rawTx: Buffer.from([1]) + })) + ) + await k('transactions').insert( + ids.map(id => ({ + ...dates, + transactionId: id, + userId: users[(id - 1) % 2], + txid: txid(id), + provenTxId: id, + status: 'completed', + reference: 'global-seek-' + id, + isOutgoing: true, + satoshis: 0, + description: '' + })) + ) + } + ) + await addSnapshotGlobalIndexes(k) + return users +} +async function observe(source, view, read, table, cursor) { + const physical = table === 'provenTxs' ? 'proven_txs' : 'proven_tx_reqs' + const key = table === 'provenTxs' ? 'provenTxId' : 'provenTxReqId' + const stats = async () => + await view.read(async trx => + Object.fromEntries( + ( + await source + .toDb(trx) + .raw( + 'SELECT OBJECT_NAME,COUNT_FETCH FROM performance_schema.table_io_waits_summary_by_table WHERE OBJECT_SCHEMA=DATABASE()' + ) + )[0].map(r => [r.OBJECT_NAME, Number(r.COUNT_FETCH)]) + ) + ) + const queries = [] + const listener = q => { + if (q.sql.startsWith('select') && q.sql.includes('snapshot_global_keys') && q.sql.includes('cross join')) + queries.push(q) + } + const before = await stats() + source.knex.on('query', listener) + let page + try { + page = await read( + table, + cursor === undefined ? undefined : { version: 1, snapshotId: 'global-native-seek', table, after: [cursor] }, + { maxRows: 16, maxBytes: 131072 } + ) + } finally { + source.knex.off('query', listener) + } + const after = await stats() + const fetches = Object.fromEntries(Object.keys(after).map(name => [name, after[name] - (before[name] ?? 0)])) + const expected = Array.from( + { length: Math.min(16, Math.ceil((8192 - (cursor ?? 0)) / 2)) }, + (_, i) => (cursor ?? 0) + i * 2 + 1 + ) + assert.deepEqual( + page.rows.map(row => row[key]), + expected + ) + assert.equal(fetches[physical], page.rows.length * 2, JSON.stringify(fetches)) + assert.equal(fetches.users ?? 0, 0) + assert.ok( + fetches.snapshot_global_keys >= page.rows.length * 2 && fetches.snapshot_global_keys <= page.rows.length * 2 + 4, + JSON.stringify(fetches) + ) + for (const [name, count] of Object.entries(fetches)) + if (![physical, 'snapshot_global_keys'].includes(name)) assert.equal(count, 0, name) + assert.equal(queries.length, 2) + const plans = [] + await runInSeries(queries, async q => { + const plan = await view.read(async trx => (await source.toDb(trx).raw('EXPLAIN ' + q.sql, q.bindings))[0]) + assert.equal(plan[0].table, 'snapshot_global_keys') + assert.equal(plan[0].key, 'snapshot_global_page') + assert.ok((cursor === undefined ? ['range', 'ref'] : ['range']).includes(plan[0].type), JSON.stringify(plan)) + plans.push(plan) + }) + return { table, cursor: cursor ?? null, rows: page.rows.length, fetches, plans } +} +async function qualifyMysqlGlobalIndexSeeks(control, connection) { + const database = 'ts569_global_read_' + randomUUID().replaceAll('-', '') + let source + try { + await control.raw('CREATE DATABASE ??', [database]) + source = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ client: 'mysql2', connection: { ...connection, database }, pool: { min: 1, max: 1 } }) + }) + await source.migrate('synthetic global reader seek', 'synthetic-global-reader-seek') + await source.makeAvailable() + const [userId] = await seed(source), + results = [] + await runInSeries(['fresh', 'refreshed'], async statistics => { + if (statistics === 'refreshed') + await source.knex.raw('ANALYZE TABLE snapshot_global_keys,proven_txs,proven_tx_reqs') + const view = await source.openReadSnapshot() + try { + const read = pageReader(source, userId, 'global-native-seek', view, true, true, true, true) + await runInSeries(['provenTxs', 'provenTxReqs'], async table => { + await runInSeries([undefined, 1500, 7000, 8180], async cursor => { + results.push({ statistics, ...(await observe(source, view, read, table, cursor)) }) + }) + }) + } finally { + await view.close() + } + }) + return { actualReaderTwoPass: true, rows: 8192, results } + } finally { + if (source) await source.destroy() + await control.raw('DROP DATABASE IF EXISTS ??', [database]) + } +} +module.exports = { qualifyMysqlGlobalIndexSeeks } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotMysqlFixtureGroups.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotMysqlFixtureGroups.cjs new file mode 100644 index 000000000..05d214bb7 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotMysqlFixtureGroups.cjs @@ -0,0 +1,13 @@ +// Every native family remains mandatory; each child retains the 60-second bound. +const mysqlFixtureGroups = Object.freeze([ + 'archive', + 'profile', + 'relation', + 'certificate', + 'global-crash', + 'global-locks', + 'global-schedules', + 'global-seeks', + 'global-integration' +]) +module.exports = { mysqlFixtureGroups } diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotGlobalFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotGlobalFixtures.ts new file mode 100644 index 000000000..97200cda0 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotGlobalFixtures.ts @@ -0,0 +1,145 @@ +import { knex, type Knex } from 'knex' +import { runInSeries } from '../../src/utility/runInSeries' + +/** Independent standard-source graph; auxiliary definitions are not its oracle. */ +export async function createGlobalSource(k: Knex, collation = 'BINARY'): Promise { + if (!['BINARY', 'NOCASE', 'RTRIM'].includes(collation)) throw new Error('Unsupported fixture collation') + const mysql = String(k.client.config.client).includes('mysql') + const integer = mysql ? 'INT UNSIGNED' : 'INTEGER' + const text = mysql ? 'VARCHAR(64)' : `VARCHAR(64) COLLATE ${collation}` + const suffix = mysql ? ' ENGINE=InnoDB' : '' + await k.raw(`CREATE TABLE proven_txs(provenTxId ${integer} NOT NULL PRIMARY KEY)${suffix}`) + await k.raw( + `CREATE TABLE proven_tx_reqs(provenTxReqId ${integer} NOT NULL PRIMARY KEY,provenTxId ${integer},txid ${text} NOT NULL UNIQUE)${suffix}` + ) + await k.raw( + `CREATE TABLE transactions(transactionId ${integer} NOT NULL PRIMARY KEY,userId ${integer} NOT NULL,provenTxId ${integer},txid ${text})${suffix}` + ) + await k.schema.alterTable('transactions', table => { + void table.index('txid') + }) +} + +export async function minimalGlobalDatabase(collation = 'BINARY'): Promise { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + try { + await createGlobalSource(k, collation) + return k + } catch (error) { + await k.destroy() + throw error + } +} + +interface Edge { + transactionId: number + requestId: number + tableId: number + rowId: number + userId: number +} +export async function expectGlobalMembership(k: Knex): Promise { + const direct: Edge[] = await k('transactions') + .select(k.raw('transactionId,0 AS requestId,1 AS tableId,provenTxId AS rowId,userId')) + .whereNotNull('provenTxId') + const requests: Edge[] = await k('transactions AS t') + .join('proven_tx_reqs AS r', 'r.txid', 't.txid') + .select(k.raw('t.transactionId,r.provenTxReqId AS requestId,0 AS tableId,r.provenTxReqId AS rowId,t.userId')) + const indirect: Edge[] = await k('transactions AS t') + .join('proven_tx_reqs AS r', 'r.txid', 't.txid') + .select(k.raw('t.transactionId,r.provenTxReqId AS requestId,1 AS tableId,r.provenTxId AS rowId,t.userId')) + .whereNotNull('r.provenTxId') + const expected = [...direct, ...requests, ...indirect] + const order = (rows: Edge[]) => + rows + .map(row => [row.transactionId, row.requestId, row.tableId, row.rowId, row.userId]) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + expect(order(await k('snapshot_global_edges'))).toEqual(order(expected)) + const proofs = new Set((await k('proven_txs').select('provenTxId')).map(row => row.provenTxId)) + const counts = new Map() + for (const row of expected) { + const key = JSON.stringify([row.tableId, row.userId, row.rowId]) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + const actual = await k('snapshot_global_keys') + expect(actual).toHaveLength(counts.size) + for (const row of actual) { + expect(Number(row.refs)).toBe(counts.get(JSON.stringify([row.tableId, row.userId, row.rowId]))) + expect(Number(row.present)).toBe(row.tableId === 0 || proofs.has(row.rowId) ? 1 : 0) + } + const guards = await k('snapshot_global_guards').orderBy('proofId') + expect(guards.map(row => row.proofId)).toEqual( + [...new Set(expected.filter(row => row.tableId === 1).map(row => row.rowId))].sort((a, b) => a - b) + ) + for (const row of guards) expect(Number(row.present)).toBe(proofs.has(row.proofId) ? 1 : 0) +} + +export interface GlobalOperation { + kind: number + id: number + userId: number + otherId: number + nullable: boolean +} +export async function clearGlobalSource(k: Knex): Promise { + await runInSeries(['transactions', 'proven_tx_reqs', 'proven_txs'], async table => { + await k(table).delete() + }) +} +export async function applyGlobalOperation(k: Knex, op: GlobalOperation): Promise { + const provenTxId = op.nullable ? null : op.otherId + const txid = op.nullable ? null : 'r' + op.otherId + try { + switch (op.kind) { + case 0: + await k('transactions') + .insert({ transactionId: op.id, userId: op.userId, txid, provenTxId }) + .onConflict('transactionId') + .merge() + break + case 1: + await k('transactions').where('transactionId', op.id).delete() + break + case 2: + await k('transactions').where('transactionId', op.id).update({ userId: op.userId, txid, provenTxId }) + break + case 3: + await k('proven_tx_reqs') + .insert({ provenTxReqId: op.id, txid: 'r' + op.id, provenTxId }) + .onConflict('provenTxReqId') + .merge() + break + case 4: + await k('proven_tx_reqs').where('provenTxReqId', op.id).delete() + break + case 5: + await k('proven_tx_reqs') + .where('provenTxReqId', op.id) + .update({ txid: 'r' + op.otherId, provenTxId }) + break + case 6: + await k('proven_txs').insert({ provenTxId: op.id }).onConflict('provenTxId').ignore() + break + case 7: + await k('proven_txs').where('provenTxId', op.id).delete() + break + case 8: + await k('proven_txs').where('provenTxId', op.id).update({ provenTxId: op.otherId }) + break + default: + throw new Error('Unsupported generated operation') + } + } catch (error) { + if ( + !['ER_DUP_ENTRY', 'SQLITE_CONSTRAINT_PRIMARYKEY', 'SQLITE_CONSTRAINT_UNIQUE'].includes( + (error as { code?: string }).code ?? '' + ) + ) + throw error + } +} diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index 7c665c68f..e6db197e3 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -21,6 +21,8 @@ const plans = new Map([ ['src/storage/schema/snapshotProfileIndexMigration.ts', 'profile-index'], ['src/storage/schema/snapshotRelationIndexMigration.ts', 'relation-index'], ['src/storage/schema/snapshotCertificateIndexMigration.ts', 'certificate-index'], + ['src/storage/schema/snapshotGlobalIndexMigration.ts', 'global-index'], + ['src/storage/schema/snapshotGlobalIndexTriggers.ts', 'global-triggers'], ['src/storage/StorageKnex.ts', 'storage'], ['src/storage/StorageProvider.ts', 'storage'] ]) diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 97bbf1513..cb445c25f 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -260,7 +260,16 @@ test('HTTP execution preserves every canonical range, full configuration and fut for (const [id, expected, fallback] of [ [ 'wallet-retained-snapshot', - ['lifecycle', 'reader', 'profile-index', 'relation-index', 'certificate-index', 'storage'], + [ + 'lifecycle', + 'reader', + 'profile-index', + 'relation-index', + 'certificate-index', + 'global-index', + 'global-triggers', + 'storage' + ], 'lifecycle' ], ['wallet-snapshot-archive', ['store', 'capture', 'source'], 'capture'], diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index f08fa84b9..e0e8eb304 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -201,7 +201,8 @@ test('additional package-relative fixture inputs select their target without rep assert.equal(Object.keys(canonical).length, 46) assert.deepEqual(canonical['wallet-retained-snapshot'].additionalInputs, [ 'test/utils/snapshotRelationFixtures.ts', - 'test/utils/snapshotCertificateFixtures.ts' + 'test/utils/snapshotCertificateFixtures.ts', + 'test/utils/snapshotGlobalFixtures.ts' ]) assert.deepEqual( selectAffectedMutationTargets(canonical, [ @@ -211,7 +212,10 @@ test('additional package-relative fixture inputs select their target without rep ) for (const input of [ 'src/storage/schema/snapshotCertificateIndexMigration.ts', + 'src/storage/schema/snapshotGlobalIndexMigration.ts', + 'src/storage/schema/snapshotGlobalIndexTriggers.ts', 'test/utils/snapshotCertificateFixtures.ts', + 'test/utils/snapshotGlobalFixtures.ts', 'src/storage/schema/snapshotRelationIndexMigration.ts', 'src/storage/schema/snapshotProfileIndexMigration.ts', 'src/storage/snapshot/RetainedReadSnapshot.property.test.ts' diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index eaae50d5b..5c00f0eaf 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -18,8 +18,13 @@ The subsequent certificate-field checkpoint preserves text collation and exact field names while indexing both direct and parent ownership. Its resumable migration and retained-view adoption include ordinary and archive paths; native process-loss, concurrent-writer and bounded-page qualification remain mandatory. -The two global proof/request tables, commit ordering, nonblocking IndexedDB and -the remaining program below remain open; this does not complete S2. +The global proof/request checkpoint adds reference edges, exact per-profile +counts and presence guards, including independent current-read writers and +resumable bounded bootstrap. Ordinary/archive selection uses complete state in +the retained view. All thirteen standard tables now have auxiliary indexed +selection paths; complete native, mutation and exact-head qualification remain +required. Commit ordering, tombstones, nonblocking IndexedDB and the remaining +program below remain open; this does not complete S2. ## Baseline and immediate defect From cc1d636b416f588e5ac787e33bf85ce0dcbf9cf4 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 16:18:59 -0700 Subject: [PATCH 079/127] test(wallet): qualify every native fixture group and late cleanup --- .../snapshotArchiveMysqlLauncher.test.ts | 88 ++++++++++++++----- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysqlLauncher.test.ts b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysqlLauncher.test.ts index 38fb22013..35e91ab3f 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysqlLauncher.test.ts +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysqlLauncher.test.ts @@ -5,9 +5,11 @@ import { runInNewContext } from 'node:vm' const source = readFileSync(join(__dirname, 'runSnapshotArchiveMysql.cjs'), 'utf8') const dockerSource = readFileSync(join(__dirname, 'snapshotArchiveDocker.cjs'), 'utf8') +const groupsSource = readFileSync(join(__dirname, 'snapshotMysqlFixtureGroups.cjs'), 'utf8') const owner = '00000000-0000-4000-8000-000000000001' const id = 'a'.repeat(64) -type Failure = 'none' | 'image' | 'create-reply' | 'child' | 'cleanup-identity' | 'cancel' +type Failure = + 'none' | 'image' | 'create-reply' | 'child' | 'late-child' | 'cleanup-identity' | 'cancel' | 'late-cancel' interface Call { executable: string args: string[] @@ -20,6 +22,8 @@ function fixture(failure: Failure) { process: { platform: 'darwin', env: {} }, require: (name: string) => (name === 'node:assert/strict' ? assert : { existsSync: () => true }) }) + const groups = { exports: {} } + runInNewContext(groupsSource, { module: groups }) const docker = helper.exports as { image: string } const metadata = { Id: id, @@ -38,7 +42,7 @@ function fixture(failure: Failure) { calls.push({ executable, args, options }) if (executable === '/synthetic/node') { childFinished = true - if (failure === 'child') throw error + if (failure === 'child' || (failure === 'late-child' && args[1] === 'global-schedules')) throw error return 'synthetic native proof\n' } assert.deepEqual(Array.from(args.slice(0, 2)), ['--context', 'desktop-linux']) @@ -76,7 +80,10 @@ function fixture(failure: Failure) { options: Record, callback: (error: unknown, stdout: string) => void ) => { - if (executable === '/synthetic/node' && failure === 'cancel') { + if ( + executable === '/synthetic/node' && + (failure === 'cancel' || (failure === 'late-cancel' && args[1] === 'global-locks')) + ) { calls.push({ executable, args, options }) const signal = options.signal as AbortSignal signal.addEventListener('abort', () => callback(error, ''), { once: true }) @@ -97,6 +104,7 @@ function fixture(failure: Failure) { 'node:crypto': { randomBytes: () => Buffer.alloc(32), randomUUID: () => owner }, 'node:path': { join }, './snapshotArchiveDocker.cjs': helper.exports, + './snapshotMysqlFixtureGroups.cjs': groups.exports, '../../out/src/utility/runInSeries.js': { runInSeries: async (items: Iterable, visit: (value: unknown) => Promise) => { for (const item of items) await visit(item) @@ -139,25 +147,55 @@ test('successful fixture retains bounds and proves exact-owner removal after its ]) { expect(creation.args).toContain(value) } - const child = f.calls.find(call => call.executable === '/synthetic/node')! - expect(child.options.timeout).toBe(60000) - expect(child.options.env).toMatchObject({ - TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, - TS_STACK_SNAPSHOT_CONTAINER_ID: id - }) + const children = f.calls.filter(call => call.executable === '/synthetic/node') + expect(children.map(child => child.args[1])).toEqual([ + 'archive', + 'profile', + 'relation', + 'certificate', + 'global-crash', + 'global-locks', + 'global-schedules', + 'global-seeks', + 'global-integration' + ]) + for (const child of children) { + expect(child.args[0]).toBe(join(__dirname, 'snapshotArchiveMysql.cjs')) + expect(child.options.timeout).toBe(60000) + expect(child.options.maxBuffer).toBe(1048576) + expect(child.options.env).toMatchObject({ + TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, + TS_STACK_SNAPSHOT_CONTAINER_ID: id + }) + } expect( f.calls.filter(call => call.executable !== '/synthetic/node').every(call => call.options.timeout === 15000) ).toBe(true) expect(f.calls.filter(call => call.args[2] === 'rm')).toHaveLength(1) }) -test.each(['create-reply', 'child'] as const)( +test.each(['create-reply', 'child', 'late-child'] as const)( 'a %s failure removes only the claimed container and preserves the failure', async failure => { const f = fixture(failure) await expect(f.run()).rejects.toBe(f.error) expect(f.exists()).toBe(false) expect(f.calls.filter(call => call.args[2] === 'rm')).toHaveLength(1) + const children = f.calls.filter(call => call.executable === '/synthetic/node') + const expected = { + 'create-reply': [], + child: ['archive'], + 'late-child': [ + 'archive', + 'profile', + 'relation', + 'certificate', + 'global-crash', + 'global-locks', + 'global-schedules' + ] + } + expect(children.map(child => child.args[1])).toEqual(expected[failure]) } ) @@ -175,14 +213,22 @@ test('an unexpected cleanup identity refuses removal and reports unproved cleanu expect(f.calls.some(call => call.args[2] === 'rm')).toBe(false) }) -test('SIGTERM cancels owned work, awaits independently bounded cleanup and removes listeners', async () => { - const f = fixture('cancel') - await expect(f.run()).rejects.toBe(f.error) - expect(f.exists()).toBe(false) - expect(f.signals.size).toBe(0) - const child = f.calls.find(call => call.executable === '/synthetic/node')! - expect((child.options.signal as AbortSignal).aborted).toBe(true) - const removal = f.calls.find(call => call.args[2] === 'rm')! - expect(removal.options.signal).toBeUndefined() - expect(removal.options.timeout).toBe(15000) -}) +test.each(['cancel', 'late-cancel'] as const)( + '%s awaits independently bounded cleanup and removes listeners', + async failure => { + const f = fixture(failure) + await expect(f.run()).rejects.toBe(f.error) + expect(f.exists()).toBe(false) + expect(f.signals.size).toBe(0) + const children = f.calls.filter(call => call.executable === '/synthetic/node') + expect(children.map(child => child.args[1])).toEqual( + failure === 'cancel' + ? ['archive'] + : ['archive', 'profile', 'relation', 'certificate', 'global-crash', 'global-locks'] + ) + expect((children.at(-1)!.options.signal as AbortSignal).aborted).toBe(true) + const removal = f.calls.find(call => call.args[2] === 'rm')! + expect(removal.options.signal).toBeUndefined() + expect(removal.options.timeout).toBe(15000) + } +) From a0e7ec198b998b451bddb71870cd1c658921ef60 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 18:17:39 -0700 Subject: [PATCH 080/127] fix(wallet): accept terminal checkpoints and qualify global indexes --- docs/guides/wallet-sync-reliability.md | 9 + docs/reference/package-api-migrations.md | 82 +-- docs/reference/test-quality-governance.md | 19 +- governance/mutation-testing/targets.mjs | 28 +- governance/package-release-notes.json | 12 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 7 + .../src/storage/remoting/StorageClientBase.ts | 4 +- .../schema/snapshotGlobalIndexBootstrap.ts | 148 +++++ .../schema/snapshotGlobalIndexMigration.ts | 521 +----------------- .../schema/snapshotGlobalIndexModel.ts | 112 ++++ .../schema/snapshotGlobalIndexMysql.ts | 144 +++++ .../schema/snapshotGlobalIndexSqlite.ts | 120 ++++ .../SnapshotGlobalIndexes.mysql.test.ts | 154 +++++- .../snapshot/SnapshotGlobalIndexes.test.ts | 92 +++- .../src/storage/sync/syncCheckpoint.test.ts | 296 +++++++--- .../src/storage/sync/syncCheckpoint.ts | 10 +- .../src/storage/sync/syncSession.test.ts | 48 ++ .../src/storage/sync/syncSession.ts | 3 +- scripts/mutation-partitions.mjs | 4 + scripts/mutation-partitions.test.mjs | 3 + scripts/mutation-testing.test.mjs | 7 + 21 files changed, 1169 insertions(+), 654 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexBootstrap.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexModel.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMysql.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 76d47e3ed..5dd06925a 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -71,6 +71,15 @@ honor returned `ProcessSyncChunkResult.error` values as well as thrown errors. Neither path advances counts or checkpoints after an error, and a nonterminal page that makes no checkpoint progress fails explicitly. +A completed unchanged backup may reset all twelve legacy offsets to zero while +retaining its `since` timestamp. Clients accept that reset only with literal +`done: true`; nonterminal and partial offset retreats still fail. State identity +and timestamp monotonicity are checked on terminal replies too. This fixes the +`Invalid sync checkpoint` error on repeated ordinary HTTP backups without +changing checkpoint JSON or persisted state. A lost terminal acknowledgement +still resumes by loading the destination's durable checkpoint. A returned +provider failure takes precedence over any accompanying checkpoint fields. + Sessions pin wallet identity, destination instance and manager generation. Switching/destroying the destination fences late replies before mutation. The page commit rechecks the durable checkpoint and any proof rows inspected during diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 6eac9ab2a..b58e23884 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. -- Migration: Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. +- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. +- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -528,8 +528,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. -- Migration: Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. +- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. +- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -540,8 +540,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. -- Migration: The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. +- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. +- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index fd43af3b4..884816885 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -198,6 +198,12 @@ lanes finish after a sibling failure, and every CI job has a reviewed timeout instead of GitHub's six-hour default. The zero-install orchestration tests enforce these resource and complete-campaign controls. +The retained-snapshot target recycles each Stryker test worker after eight mutant +executions to bound accumulated worker state. Every replacement worker runs the +same canonical test selection; the complete mutant union, four-worker concurrency, +property seeds and budgets, per-test limits and aggregate gates remain unchanged. +Worker exits and incomplete reports remain failed qualification evidence. + The retained-snapshot campaign and the three snapshot-sync groups have a 90-minute limit in PR CI and the standalone mutation workflow. The retained campaign completed within that allowance, but the complete snapshot-sync campaign @@ -234,9 +240,16 @@ each execute as a whole-file part, alongside reader, storage and the lifecycle fallback. The subsequent certificate-field migration is registered as another complete source and whole-file part with the same full retained test selection; its independently defined fixture is included in the input digest. Global -proof/request migration and trigger sources are each registered in full and run -in disjoint whole-file parts, with the independent global fixture included in -the same retained input digest. These parts preserve the complete canonical +proof/request migration, model, MySQL metadata, SQLite metadata, bootstrap and +trigger sources are each registered in full, with the independent global fixture +included in the same retained input digest. The entry point and model share +`global-index`; the metadata backends, bootstrap and triggers each run in a +separate whole-file part. This follows a complete local monolithic run lasting +64 minutes that failed the zero-invalid gate; that failed evidence is retained. +The extraction preserves the original declaration bodies, and the separately +tested descriptor/SQL-binding corrections make broken variants fail within the +awaited migration. Every part retains the complete canonical test selection and +the existing 90-minute limit. These parts preserve the complete canonical source and test union. Archive groups store/migration, source/closure and the capture fallback. Remote reader groups lease, rows, page/open/cursor and the admission fallback. These partitions retain every original source specification and full test diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index eb90efed9..a2ab18313 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -17,10 +17,16 @@ function sourceLineRange(repositoryRoot, packageDirectory, filePath, startMarker return `${filePath}:${startIndex + 1}-${endIndex}` } -function jestTarget(configFile, testMatch, { esm = false, config = {}, findRelated = false } = {}) { +function jestTarget( + configFile, + testMatch, + { esm = false, config = {}, findRelated = false, buildCommand, maxTestRunnerReuse } = {} +) { return { testRunner: 'jest', runnerOptions: { + ...(buildCommand ? { buildCommand } : {}), + ...(maxTestRunnerReuse === undefined ? {} : { maxTestRunnerReuse }), jest: { projectType: 'custom', configFile, @@ -61,6 +67,14 @@ function snapshotSyncMutationTargets(repositoryRoot) { 'packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts', mutate: [ 'src/utility/runInSeries.ts', + 'src/storage/sync/syncCheckpoint.ts', + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/sync/syncSession.ts', + 'async function committedCheckpoint(', + '/** One page in flight;' + ), 'src/storage/snapshot/SnapshotSync.ts', 'src/storage/snapshot/SnapshotSyncRows.ts', 'src/storage/snapshot/KnexSnapshotSyncDestination.ts', @@ -163,6 +177,7 @@ function snapshotSyncMutationTargets(repositoryRoot) { '/src/storage/methods/validateSyncProof.test.ts', '/src/storage/sync/syncFailure.test.ts', '/src/storage/sync/syncSession.test.ts', + '/src/storage/sync/syncCheckpoint.test.ts', '/src/utility/__tests__/runInSeries.test.ts' ], { @@ -412,6 +427,10 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/schema/snapshotRelationIndexMigration.ts', 'src/storage/schema/snapshotCertificateIndexMigration.ts', 'src/storage/schema/snapshotGlobalIndexMigration.ts', + 'src/storage/schema/snapshotGlobalIndexModel.ts', + 'src/storage/schema/snapshotGlobalIndexMysql.ts', + 'src/storage/schema/snapshotGlobalIndexSqlite.ts', + 'src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'src/storage/schema/snapshotGlobalIndexTriggers.ts', sourceLineRange( repositoryRoot, @@ -429,6 +448,7 @@ export function buildMutationTargets(repositoryRoot) { ) ], ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/*.test.ts'], { + maxTestRunnerReuse: 8, config: { moduleNameMapper: { '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), @@ -541,6 +561,11 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/archive/KnexSnapshotArchiveRpc.ts', 'src/storage/snapshot/archive/SnapshotArchiveTransport.ts', ...[ + [ + 'src/storage/remoting/StorageClientBase.ts', + 'async processSyncChunk(', + 'async getSyncChunk(' + ], [ 'src/storage/remoting/StorageClientBase.ts', 'if (properties.snapshotArchive != null)', @@ -614,6 +639,7 @@ export function buildMutationTargets(repositoryRoot) { '/src/storage/remoting/__test/RateLimitPolicy.test.ts', '/src/storage/remoting/__test/StorageServerRpc.test.ts', '/src/storage/remoting/__test/StorageClientBase.*.test.ts', + '/src/storage/sync/syncCheckpoint.test.ts', '/src/storage/remoting/__test/StorageClient.security.test.ts', '/src/storage/remoting/__test/StorageClient.transport.security.test.ts', '/src/storage/remoting/__test/StorageClient.telemetry.test.ts' diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index ef36c772e..fa158118e 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,22 +210,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view.", - "migration": "Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off." + "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved.", + "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns.", - "migration": "Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." + "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns.", + "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor.", - "migration": "The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." + "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor.", + "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." }, { "name": "create-bsv-app", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index a3c2e692b..07e31dc2a 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,13 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Accept a valid all-zero terminal offset reset when an unchanged backup keeps + the same timestamp. Preserve remote state binding, monotonic timestamps and + nonterminal/partial-reset checks in HTTP and resumable sessions. Repeated + ordinary/binary HTTP backups and lost terminal acknowledgements are covered; + returned provider failures retain precedence over checkpoint fields. Checkpoint + bytes and stored schemas remain unchanged. + - Add resumable global proof/request ownership indexes with exact reference counts and proof-presence guards. Preserve both ownership bases, current independent-writer changes and pinned ordinary/archive views. Bootstrap, diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index 934ee4f5c..98a31bc4d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -948,7 +948,9 @@ export abstract class StorageClientBase implements WalletStorageProvider { bytes != null && bytes.length * expansion > (capabilities!.inlineBytes ?? 6 * 1024 * 1024) ? await this.uploadSyncTransfer(args.identityKey, bytes, capabilities!) : await this.rpcCall('processSyncChunk', [args, wireChunk]) - if (r.nextCheckpoint != null) r.nextCheckpoint = validateSyncCheckpoint(r.nextCheckpoint, args) + // Preserve the failure result; its error takes precedence over checkpoint fields. + if (r.error == null && r.nextCheckpoint != null) + r.nextCheckpoint = validateSyncCheckpoint(r.nextCheckpoint, args, r.done === true) return r } diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexBootstrap.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexBootstrap.ts new file mode 100644 index 000000000..effac4a8e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexBootstrap.ts @@ -0,0 +1,148 @@ +import type { Knex } from 'knex' +import { runInSeries } from '../../utility/runInSeries' +import { EDGES, GUARDS, PROGRESS, PAGE_ROWS, mysql, invalid } from './snapshotGlobalIndexModel' + +export interface Position { + afterRowId: number + complete: boolean | number +} +export function validPosition(state: Position | undefined): state is Position { + return ( + state !== undefined && + Number.isSafeInteger(state.afterRowId) && + state.afterRowId >= 0 && + [false, true, 0, 1].includes(state.complete) + ) +} +function positive(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) invalid('Invalid snapshot global source key') + return value +} +interface SourceRow { + transactionId: number + userId: number + provenTxId: number | null + txid: string | null + txidBytes: number +} +interface RequestRow { + provenTxReqId: number + provenTxId: number | null +} +async function currentProof(k: Knex, proofId: number): Promise { + positive(proofId) + await k(GUARDS) + .insert({ proofId, present: false }) + .onConflict('proofId') + .merge({ proofId: k.ref('proofId') }) + // This lock is shared with proof insert/delete triggers, including absence. + // UPDATE reads the current source after acquiring the auxiliary target lock. + if (mysql(k)) + await k.raw( + 'UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=?', + [proofId] + ) + else + await k(GUARDS) + .where('proofId', proofId) + .update({ + present: k.raw('EXISTS(SELECT 1 FROM proven_txs WHERE provenTxId=?)', [proofId]) + }) +} +async function bootstrapRow(k: Knex, row: SourceRow): Promise { + positive(row.transactionId) + positive(row.userId) + if ( + !Number.isSafeInteger(row.txidBytes) || + row.txidBytes < 0 || + row.txidBytes > 256 || + (row.txid !== null && (typeof row.txid !== 'string' || Array.from(row.txid).length > 64)) + ) + invalid('Invalid snapshot global transaction key') + let request: RequestRow | undefined + if (row.txid !== null) { + const query = k('proven_tx_reqs').select('provenTxReqId', 'provenTxId').where('txid', row.txid) + if (mysql(k)) void query.forShare() + request = await query.first() + if (request !== undefined) positive(request.provenTxReqId) + } + const proofs = [ + ...new Set( + [row.provenTxId, request?.provenTxId].filter((value): value is number => value !== null && value !== undefined) + ) + ].sort((a, b) => a - b) + await runInSeries(proofs, async proofId => { + await currentProof(k, proofId) + }) + const values: Array<{ requestId: number; tableId: number; rowId: number }> = [] + if (row.provenTxId !== null) values.push({ requestId: 0, tableId: 1, rowId: positive(row.provenTxId) }) + if (request !== undefined) { + values.push({ + requestId: request.provenTxReqId, + tableId: 0, + rowId: request.provenTxReqId + }) + if (request.provenTxId !== null) + values.push({ + requestId: request.provenTxReqId, + tableId: 1, + rowId: positive(request.provenTxId) + }) + } + if (values.length !== 0) + await k(EDGES) + .insert( + values.map(value => ({ + ...value, + transactionId: row.transactionId, + userId: row.userId + })) + ) + .onConflict(['transactionId', 'requestId', 'tableId', 'rowId']) + .merge({ transactionId: k.ref('transactionId') }) +} +export async function bootstrapPage(k: Knex): Promise { + return await k.transaction(async trx => { + if (!mysql(k)) + await trx(PROGRESS) + .where('id', 0) + .update({ complete: trx.ref('complete') }) + const position = trx(PROGRESS).where('id', 0) + if (mysql(k)) void position.forUpdate() + const state: Position | undefined = await position.first() + if (!validPosition(state)) invalid('Invalid snapshot global bootstrap position') + if (state.complete === true || state.complete === 1) return true + if (state.afterRowId === 0) { + // The initial position is below every supported source key. Do not mark a + // malformed legacy SQLite store complete while silently skipping its rows. + const unsupported = trx('transactions').select('transactionId').where('transactionId', '<=', 0) + if (mysql(k)) void unsupported.forUpdate() + if ((await unsupported.first()) !== undefined) invalid('Invalid snapshot global source key') + } + const length = mysql(k) ? 'octet_length(txid)' : 'length(cast(txid AS blob))' + const source = trx('transactions') + .select( + 'transactionId', + 'userId', + 'provenTxId', + trx.raw(`CASE WHEN ${length} <= ? THEN txid END AS txid`, [256]), + trx.raw(`COALESCE(${length},?) AS txidBytes`, [0]) + ) + .where('transactionId', '>', state.afterRowId) + .orderBy('transactionId') + .limit(PAGE_ROWS) + if (mysql(k)) void source.forUpdate() + const rows: SourceRow[] = await source + await runInSeries(rows, async row => { + await bootstrapRow(trx, row) + }) + const complete = rows.length < PAGE_ROWS + await trx(PROGRESS) + .where('id', 0) + .update({ + afterRowId: rows.at(-1)?.transactionId ?? state.afterRowId, + complete + }) + return complete + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts index 4f0a0f53b..95f4b6534 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts @@ -1,213 +1,12 @@ import type { Knex } from 'knex' -import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' import { runInSeries } from '../../utility/runInSeries' import { snapshotGlobalIndexTriggers, type SnapshotGlobalIndexTrigger } from './snapshotGlobalIndexTriggers' +import { tables, PROGRESS, mysql, normalized, invalid, type Table } from './snapshotGlobalIndexModel' +import { mysqlParts, mysqlTable, validateMysqlSource } from './snapshotGlobalIndexMysql' +import { sqliteTable, validateSqliteSource } from './snapshotGlobalIndexSqlite' +import { validPosition, bootstrapPage, type Position } from './snapshotGlobalIndexBootstrap' export const SNAPSHOT_GLOBAL_INDEX_MIGRATION = '2026-10-01-006 add snapshot global reference indexes' -const EDGES = 'snapshot_global_edges' -const KEYS = 'snapshot_global_keys' -const GUARDS = 'snapshot_global_guards' -const PROGRESS = 'snapshot_global_index_progress' -const PAGE_ROWS = 256 - -type ColumnType = 'int' | 'uint' | 'biguint' | 'boolean' -interface Column { - name: string - type: ColumnType -} -interface Index { - name: string - columns: string[] -} -interface Table { - name: string - columns: Column[] - primary: string[] - indexes: Index[] -} -const columns = (names: string[], type: ColumnType): Column[] => names.map(name => ({ name, type })) -const tables: Table[] = [ - { - name: GUARDS, - columns: [...columns(['proofId'], 'uint'), ...columns(['present'], 'boolean')], - primary: ['proofId'], - indexes: [] - }, - { - name: KEYS, - columns: [ - ...columns(['tableId'], 'int'), - ...columns(['userId', 'rowId'], 'uint'), - ...columns(['refs'], 'biguint'), - ...columns(['present'], 'boolean') - ], - primary: ['tableId', 'userId', 'rowId'], - indexes: [ - { - name: 'snapshot_global_page', - columns: ['tableId', 'userId', 'present', 'rowId'] - }, - { - name: 'snapshot_global_target', - columns: ['tableId', 'rowId', 'userId'] - } - ] - }, - { - name: EDGES, - columns: [ - ...columns(['transactionId', 'requestId'], 'uint'), - ...columns(['tableId'], 'int'), - ...columns(['rowId', 'userId'], 'uint') - ], - primary: ['transactionId', 'requestId', 'tableId', 'rowId'], - indexes: [ - { - name: 'snapshot_global_request', - columns: ['requestId', 'transactionId'] - } - ] - }, - { - name: PROGRESS, - columns: [...columns(['id'], 'int'), ...columns(['afterRowId'], 'uint'), ...columns(['complete'], 'boolean')], - primary: ['id'], - indexes: [] - } -] -const mysql = (k: Knex): boolean => String(k.client.config.client).includes('mysql') -const normalized = (sql: string): string => sql.replaceAll(/\s+/g, ' ').trim() -function invalid(message: string): never { - throw new WERR_INVALID_OPERATION(message) -} -interface MysqlPart { - name: string - columnName: string - nonUnique: number - direction: string - prefix: unknown -} -async function mysqlParts(k: Knex, table: string): Promise { - const [parts]: MysqlPart[][] = await k.raw( - 'SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX', - [table] - ) - if (!Array.isArray(parts)) invalid('Invalid snapshot global index metadata') - return parts -} -function matchesMysqlIndex(parts: MysqlPart[], name: string, expected: string[]): boolean { - const found = parts.filter(part => part.name === name) - return ( - found.length === expected.length && - found.every((part, i) => part.columnName === expected[i] && part.direction === 'A' && part.prefix === null) - ) -} -async function mysqlTable(k: Knex, table: Table, secondary: boolean): Promise { - const [engines]: Array> = await k.raw( - 'SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', - [table.name] - ) - if (engines.length !== 1 || engines[0]?.engine !== 'InnoDB') return false - const [actual]: Array< - Array<{ - name: string - type: string - nullable: string - defaultValue: unknown - extra: string - }> - > = await k.raw( - 'SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION', - [table.name] - ) - const types = { - int: 'int', - uint: 'int unsigned', - biguint: 'bigint unsigned', - boolean: 'tinyint' - } - if ( - !Array.isArray(actual) || - actual.length !== table.columns.length || - actual.some( - (column, i) => - column.name !== table.columns[i].name || - column.type.replaceAll(/\(\d+\)/g, '') !== types[table.columns[i].type] || - column.nullable !== 'NO' || - column.defaultValue !== null || - column.extra !== '' - ) - ) - return false - const parts = await mysqlParts(k, table.name) - if (parts.some(part => part.name !== 'PRIMARY' && Number(part.nonUnique) !== 1)) return false - if (!matchesMysqlIndex(parts, 'PRIMARY', table.primary)) return false - return !secondary || table.indexes.every(index => matchesMysqlIndex(parts, index.name, index.columns)) -} -interface SqlitePart { - name: string - desc: number - coll: string - key: number -} -async function sqliteParts(k: Knex, name: string): Promise { - const rows: SqlitePart[] = await k.raw('PRAGMA index_xinfo(??)', [name]) - return rows.filter(row => row.key === 1) -} -async function sqliteTable(k: Knex, table: Table, secondary: boolean): Promise { - const actual: Array<{ - name: string - type: string - notnull: number - dflt_value: unknown - pk: number - hidden: number - }> = await k.raw('PRAGMA table_xinfo(??)', [table.name]) - const types = { - int: 'integer', - uint: 'integer', - biguint: 'bigint', - boolean: 'boolean' - } - if ( - !Array.isArray(actual) || - actual.length !== table.columns.length || - actual.some( - (column, i) => - column.name !== table.columns[i].name || - column.type.toLowerCase() !== types[table.columns[i].type] || - column.notnull !== 1 || - column.dflt_value !== null || - column.pk !== table.primary.indexOf(column.name) + 1 || - column.hidden !== 0 - ) - ) - return false - const indexes: Array<{ - name: string - unique: number - origin: string - partial: number - }> = await k.raw('PRAGMA index_list(??)', [table.name]) - if (indexes.some(index => index.unique !== 0 && (index.origin !== 'pk' || index.partial !== 0))) return false - const required: Index[] = secondary ? [...table.indexes] : [] - if (table.primary.length > 1) { - const primary = indexes.find(index => index.origin === 'pk') - if (primary === undefined) return false - required.push({ name: primary.name, columns: table.primary }) - } - for (const index of required) { - const found = indexes.find(value => value.name === index.name) - if (found === undefined || found.partial !== 0) return false - const parts = await sqliteParts(k, found.name) - if ( - parts.length !== index.columns.length || - parts.some((part, i) => part.name !== index.columns[i] || part.desc !== 0 || part.coll !== 'BINARY') - ) - return false - } - return true -} async function validateTable(k: Knex, table: Table, secondary = true): Promise { if (!(mysql(k) ? await mysqlTable(k, table, secondary) : await sqliteTable(k, table, secondary))) invalid('Snapshot global table definition mismatch') @@ -238,165 +37,6 @@ async function ensureTable(k: Knex, table: Table): Promise { }) await validateTable(k, table) } - -const sources = [ - { - name: 'proven_txs', - key: 'provenTxId', - fields: [{ name: 'provenTxId', nullable: false, text: false }] - }, - { - name: 'proven_tx_reqs', - key: 'provenTxReqId', - fields: [ - { name: 'provenTxReqId', nullable: false, text: false }, - { name: 'provenTxId', nullable: true, text: false }, - { name: 'txid', nullable: false, text: true } - ] - }, - { - name: 'transactions', - key: 'transactionId', - fields: [ - { name: 'transactionId', nullable: false, text: false }, - { name: 'userId', nullable: false, text: false }, - { name: 'provenTxId', nullable: true, text: false }, - { name: 'txid', nullable: true, text: true } - ] - } -] -interface MysqlSourceColumn { - name: string - type: string - nullable: string - extra: string - charset: string | null - collation: string | null -} -type SourceDefinition = (typeof sources)[number] -type SourceField = SourceDefinition['fields'][number] -interface SourceText { - charset: string - collation: string -} -function mysqlSourceColumn(column: MysqlSourceColumn | undefined, field: SourceField, key: string): MysqlSourceColumn { - const type = field.text ? 'varchar(64)' : 'int unsigned' - if ( - column === undefined || - (field.text ? column.type : column.type.replaceAll(/\(\d+\)/g, '')) !== type || - column.nullable !== (field.nullable ? 'YES' : 'NO') || - (column.extra !== '' && !(field.name === key && column.extra === 'auto_increment')) - ) - invalid('Unsupported snapshot global source column') - return column -} -function mysqlSourceText(previous: SourceText | undefined, column: MysqlSourceColumn): SourceText { - if (column.charset === null || column.collation === null) invalid('Unsupported snapshot global source text') - if (previous !== undefined && (previous.charset !== column.charset || previous.collation !== column.collation)) - invalid('Snapshot global source comparisons require matching text definitions') - return { charset: column.charset, collation: column.collation } -} -function validateMysqlSourceIndexes(parts: MysqlPart[], source: SourceDefinition): void { - if (!matchesMysqlIndex(parts, 'PRIMARY', [source.key])) invalid('Unsupported snapshot global source key') - if (source.name === 'proven_txs') return - const candidates = [...new Set(parts.map(part => part.name))] - const indexed = candidates.some(name => { - const index = parts.filter(part => part.name === name) - return ( - index[0]?.columnName === 'txid' && - index[0].direction === 'A' && - index[0].prefix === null && - (source.name === 'transactions' || (index.length === 1 && Number(index[0].nonUnique) === 0)) - ) - }) - if (!indexed) invalid('Snapshot global source requires complete transaction lookup indexes') -} -async function validateMysqlSource(k: Knex): Promise { - let text: SourceText | undefined - await runInSeries(sources, async source => { - const [engines]: Array> = await k.raw( - 'SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', - [source.name] - ) - if (engines.length !== 1 || engines[0]?.engine !== 'InnoDB') - invalid('Snapshot global source requires transactional tables') - const [rules]: Array> = await k.raw( - 'SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?', - [source.name] - ) - // InnoDB cascades do not run the affected child's row triggers. Keep the - // standard RESTRICT/NO ACTION definitions; do not adopt a stale edge index. - const explicit = (rule: string): boolean => rule === 'RESTRICT' || rule === 'NO ACTION' - if (!Array.isArray(rules) || rules.some(rule => !explicit(rule.updateRule) || !explicit(rule.deleteRule))) - invalid('Snapshot global source requires explicit row mutations') - const [actual]: MysqlSourceColumn[][] = await k.raw( - 'SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', - [source.name] - ) - for (const field of source.fields) { - const column = mysqlSourceColumn( - actual.find(value => value.name === field.name), - field, - source.key - ) - if (field.text) text = mysqlSourceText(text, column) - } - validateMysqlSourceIndexes(await mysqlParts(k, source.name), source) - }) -} -async function sqliteTextOrder(k: Knex, table: string): Promise { - const indexes: Array<{ name: string; unique: number; partial: number }> = await k.raw('PRAGMA index_list(??)', [ - table - ]) - for (const index of indexes) { - if (index.partial !== 0 || (table === 'proven_tx_reqs' && index.unique !== 1)) continue - const parts = await sqliteParts(k, index.name) - if ( - parts[0]?.name !== 'txid' || - parts[0].desc !== 0 || - !['BINARY', 'NOCASE', 'RTRIM'].includes(parts[0].coll) || - (table === 'proven_tx_reqs' && parts.length !== 1) - ) - continue - const plan: Array<{ detail: string }> = await k.raw( - 'EXPLAIN QUERY PLAN SELECT txid FROM ?? INDEXED BY ?? ORDER BY txid LIMIT 1', - [table, index.name] - ) - if (!plan.some(step => step.detail.includes('TEMP B-TREE'))) return parts[0].coll - } - return invalid('Snapshot global source requires complete transaction lookup indexes') -} -async function validateSqliteSource(k: Knex): Promise { - let collation: string | undefined - await runInSeries(sources, async source => { - const actual: Array<{ - name: string - type: string - notnull: number - pk: number - hidden: number - }> = await k.raw('PRAGMA table_xinfo(??)', [source.name]) - const primary = actual.filter(column => column.pk !== 0) - if (primary.length !== 1 || primary[0]?.name !== source.key || primary[0].pk !== 1) - invalid('Unsupported snapshot global source key') - for (const field of source.fields) { - const column = actual.find(value => value.name === field.name) - if ( - column === undefined || - column.type.toLowerCase() !== (field.text ? 'varchar(64)' : 'integer') || - column.hidden !== 0 || - (field.name !== source.key && column.notnull !== (field.nullable ? 0 : 1)) - ) - invalid('Unsupported snapshot global source column') - } - if (source.name !== 'proven_txs') { - const order = await sqliteTextOrder(k, source.name) - if (collation !== undefined && collation !== order) - invalid('Snapshot global source comparisons require matching text definitions') - collation = order - } - }) -} async function validateSource(k: Knex): Promise { if (mysql(k)) await validateMysqlSource(k) else await validateSqliteSource(k) @@ -427,156 +67,11 @@ async function validateTrigger(k: Knex, expected: SnapshotGlobalIndexTrigger): P invalid('Snapshot global trigger definition mismatch') return true } - -interface Position { - afterRowId: number - complete: boolean | number -} -function validPosition(state: Position | undefined): state is Position { - return ( - state !== undefined && - Number.isSafeInteger(state.afterRowId) && - state.afterRowId >= 0 && - [false, true, 0, 1].includes(state.complete) - ) -} -function positive(value: number): number { - if (!Number.isSafeInteger(value) || value < 1) invalid('Invalid snapshot global source key') - return value -} -interface SourceRow { - transactionId: number - userId: number - provenTxId: number | null - txid: string | null - txidBytes: number -} -interface RequestRow { - provenTxReqId: number - provenTxId: number | null -} -async function currentProof(k: Knex, proofId: number): Promise { - positive(proofId) - await k(GUARDS) - .insert({ proofId, present: false }) - .onConflict('proofId') - .merge({ proofId: k.ref('proofId') }) - // This lock is shared with proof insert/delete triggers, including absence. - // UPDATE reads the current source after acquiring the auxiliary target lock. - if (mysql(k)) - await k.raw( - 'UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=?', - [proofId] - ) - else - await k(GUARDS) - .where('proofId', proofId) - .update({ - present: k.raw('EXISTS(SELECT 1 FROM proven_txs WHERE provenTxId=?)', [proofId]) - }) -} -async function bootstrapRow(k: Knex, row: SourceRow): Promise { - positive(row.transactionId) - positive(row.userId) - if ( - !Number.isSafeInteger(row.txidBytes) || - row.txidBytes < 0 || - row.txidBytes > 256 || - (row.txid !== null && (typeof row.txid !== 'string' || Array.from(row.txid).length > 64)) - ) - invalid('Invalid snapshot global transaction key') - let request: RequestRow | undefined - if (row.txid !== null) { - const query = k('proven_tx_reqs').select('provenTxReqId', 'provenTxId').where('txid', row.txid) - if (mysql(k)) void query.forShare() - request = await query.first() - if (request !== undefined) positive(request.provenTxReqId) - } - const proofs = [ - ...new Set( - [row.provenTxId, request?.provenTxId].filter((value): value is number => value !== null && value !== undefined) - ) - ].sort((a, b) => a - b) - await runInSeries(proofs, async proofId => { - await currentProof(k, proofId) - }) - const values: Array<{ requestId: number; tableId: number; rowId: number }> = [] - if (row.provenTxId !== null) values.push({ requestId: 0, tableId: 1, rowId: positive(row.provenTxId) }) - if (request !== undefined) { - values.push({ - requestId: request.provenTxReqId, - tableId: 0, - rowId: request.provenTxReqId - }) - if (request.provenTxId !== null) - values.push({ - requestId: request.provenTxReqId, - tableId: 1, - rowId: positive(request.provenTxId) - }) - } - if (values.length !== 0) - await k(EDGES) - .insert( - values.map(value => ({ - ...value, - transactionId: row.transactionId, - userId: row.userId - })) - ) - .onConflict(['transactionId', 'requestId', 'tableId', 'rowId']) - .merge({ transactionId: k.ref('transactionId') }) -} -async function bootstrapPage(k: Knex): Promise { - return await k.transaction(async trx => { - if (!mysql(k)) - await trx(PROGRESS) - .where('id', 0) - .update({ complete: trx.ref('complete') }) - const position = trx(PROGRESS).where('id', 0) - if (mysql(k)) void position.forUpdate() - const state: Position | undefined = await position.first() - if (!validPosition(state)) invalid('Invalid snapshot global bootstrap position') - if (state.complete === true || state.complete === 1) return true - if (state.afterRowId === 0) { - // The initial position is below every supported source key. Do not mark a - // malformed legacy SQLite store complete while silently skipping its rows. - const unsupported = trx('transactions').select('transactionId').where('transactionId', '<=', 0) - if (mysql(k)) void unsupported.forUpdate() - if ((await unsupported.first()) !== undefined) invalid('Invalid snapshot global source key') - } - const length = mysql(k) ? 'octet_length(txid)' : 'length(cast(txid AS blob))' - const source = trx('transactions') - .select( - 'transactionId', - 'userId', - 'provenTxId', - trx.raw(`CASE WHEN ${length} <= 256 THEN txid END AS txid`), - trx.raw(`COALESCE(${length},0) AS txidBytes`) - ) - .where('transactionId', '>', state.afterRowId) - .orderBy('transactionId') - .limit(PAGE_ROWS) - if (mysql(k)) void source.forUpdate() - const rows: SourceRow[] = await source - await runInSeries(rows, async row => { - await bootstrapRow(trx, row) - }) - const complete = rows.length < PAGE_ROWS - await trx(PROGRESS) - .where('id', 0) - .update({ - afterRowId: rows.at(-1)?.transactionId ?? state.afterRowId, - complete - }) - return complete - }) -} export async function addSnapshotGlobalIndexes(k: Knex): Promise { if (mysql(k) && k.isTransaction) invalid('Snapshot global migration requires independent DDL and bootstrap transactions') await validateSource(k) - await runInSeries(tables, async table => { + await runInSeries(tables(), async table => { await ensureTable(k, table) }) await runInSeries(snapshotGlobalIndexTriggers(mysql(k)), async trigger => { @@ -596,7 +91,7 @@ export async function removeSnapshotGlobalIndexes(k: Knex): Promise { if (mysql(k) && k.isTransaction) invalid('Snapshot global migration requires independent DDL and bootstrap transactions') await validateSource(k) - await runInSeries(tables, async table => { + await runInSeries(tables(), async table => { if (await k.schema.hasTable(table.name)) await validateTable(k, table) }) const triggers = snapshotGlobalIndexTriggers(mysql(k)).reverse() @@ -606,7 +101,7 @@ export async function removeSnapshotGlobalIndexes(k: Knex): Promise { await runInSeries(triggers, async trigger => { await k.raw('DROP TRIGGER IF EXISTS ??', [trigger.name]) }) - await runInSeries([...tables].reverse(), async table => { + await runInSeries([...tables()].reverse(), async table => { await k.schema.dropTableIfExists(table.name) }) } @@ -619,7 +114,7 @@ export async function readSnapshotGlobalIndexState(k: Knex, config?: Knex.Migrat if (config?.schemaName !== undefined) void journal.withSchema(config.schemaName) if ((await journal.first('name')) === undefined) return false await validateSource(k) - await runInSeries(tables, async table => { + await runInSeries(tables(), async table => { if (!(await k.schema.hasTable(table.name))) invalid('Snapshot global index migration is incomplete') await validateTable(k, table) }) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexModel.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexModel.ts new file mode 100644 index 000000000..5b50755c4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexModel.ts @@ -0,0 +1,112 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +export const EDGES = 'snapshot_global_edges' +export const KEYS = 'snapshot_global_keys' +export const GUARDS = 'snapshot_global_guards' +export const PROGRESS = 'snapshot_global_index_progress' +export const PAGE_ROWS = 256 + +type ColumnType = 'int' | 'uint' | 'biguint' | 'boolean' +interface Column { + name: string + type: ColumnType +} +export interface Index { + name: string + columns: string[] +} +export interface Table { + name: string + columns: Column[] + primary: string[] + indexes: Index[] +} +const columns = (names: string[], type: ColumnType): Column[] => names.map(name => ({ name, type })) +// Build descriptors when the migration runs, so construction failures belong to +// the awaited migration operation rather than module initialization. +export function tables(): Table[] { + return [ + { + name: GUARDS, + columns: [...columns(['proofId'], 'uint'), ...columns(['present'], 'boolean')], + primary: ['proofId'], + indexes: [] + }, + { + name: KEYS, + columns: [ + ...columns(['tableId'], 'int'), + ...columns(['userId', 'rowId'], 'uint'), + ...columns(['refs'], 'biguint'), + ...columns(['present'], 'boolean') + ], + primary: ['tableId', 'userId', 'rowId'], + indexes: [ + { + name: 'snapshot_global_page', + columns: ['tableId', 'userId', 'present', 'rowId'] + }, + { + name: 'snapshot_global_target', + columns: ['tableId', 'rowId', 'userId'] + } + ] + }, + { + name: EDGES, + columns: [ + ...columns(['transactionId', 'requestId'], 'uint'), + ...columns(['tableId'], 'int'), + ...columns(['rowId', 'userId'], 'uint') + ], + primary: ['transactionId', 'requestId', 'tableId', 'rowId'], + indexes: [ + { + name: 'snapshot_global_request', + columns: ['requestId', 'transactionId'] + } + ] + }, + { + name: PROGRESS, + columns: [...columns(['id'], 'int'), ...columns(['afterRowId'], 'uint'), ...columns(['complete'], 'boolean')], + primary: ['id'], + indexes: [] + } + ] +} + +export const mysql = (k: Knex): boolean => String(k.client.config.client).includes('mysql') +export const normalized = (sql: string): string => sql.replaceAll(/\s+/g, ' ').trim() +export function invalid(message: string): never { + throw new WERR_INVALID_OPERATION(message) +} + +export const sources = [ + { + name: 'proven_txs', + key: 'provenTxId', + fields: [{ name: 'provenTxId', nullable: false, text: false }] + }, + { + name: 'proven_tx_reqs', + key: 'provenTxReqId', + fields: [ + { name: 'provenTxReqId', nullable: false, text: false }, + { name: 'provenTxId', nullable: true, text: false }, + { name: 'txid', nullable: false, text: true } + ] + }, + { + name: 'transactions', + key: 'transactionId', + fields: [ + { name: 'transactionId', nullable: false, text: false }, + { name: 'userId', nullable: false, text: false }, + { name: 'provenTxId', nullable: true, text: false }, + { name: 'txid', nullable: true, text: true } + ] + } +] +export type SourceDefinition = (typeof sources)[number] +export type SourceField = SourceDefinition['fields'][number] diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMysql.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMysql.ts new file mode 100644 index 000000000..bb90409f4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMysql.ts @@ -0,0 +1,144 @@ +import type { Knex } from 'knex' +import { runInSeries } from '../../utility/runInSeries' +import { invalid, sources, type Table, type SourceDefinition, type SourceField } from './snapshotGlobalIndexModel' +interface MysqlPart { + name: string + columnName: string + nonUnique: number + direction: string + prefix: unknown +} +export async function mysqlParts(k: Knex, table: string): Promise { + const [parts]: MysqlPart[][] = await k.raw( + 'SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX', + [table] + ) + if (!Array.isArray(parts)) invalid('Invalid snapshot global index metadata') + return parts +} +function matchesMysqlIndex(parts: MysqlPart[], name: string, expected: string[]): boolean { + const found = parts.filter(part => part.name === name) + return ( + found.length === expected.length && + found.every((part, i) => part.columnName === expected[i] && part.direction === 'A' && part.prefix === null) + ) +} +export async function mysqlTable(k: Knex, table: Table, secondary: boolean): Promise { + const [engines]: Array> = await k.raw( + 'SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [table.name] + ) + if (engines.length !== 1 || engines[0]?.engine !== 'InnoDB') return false + const [actual]: Array< + Array<{ + name: string + type: string + nullable: string + defaultValue: unknown + extra: string + }> + > = await k.raw( + 'SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION', + [table.name] + ) + const types = { + int: 'int', + uint: 'int unsigned', + biguint: 'bigint unsigned', + boolean: 'tinyint' + } + if ( + !Array.isArray(actual) || + actual.length !== table.columns.length || + actual.some( + (column, i) => + column.name !== table.columns[i].name || + column.type.replaceAll(/\(\d+\)/g, '') !== types[table.columns[i].type] || + column.nullable !== 'NO' || + column.defaultValue !== null || + column.extra !== '' + ) + ) + return false + const parts = await mysqlParts(k, table.name) + if (parts.some(part => part.name !== 'PRIMARY' && Number(part.nonUnique) !== 1)) return false + if (!matchesMysqlIndex(parts, 'PRIMARY', table.primary)) return false + return !secondary || table.indexes.every(index => matchesMysqlIndex(parts, index.name, index.columns)) +} +interface MysqlSourceColumn { + name: string + type: string + nullable: string + extra: string + charset: string | null + collation: string | null +} +interface SourceText { + charset: string + collation: string +} +function mysqlSourceColumn(column: MysqlSourceColumn | undefined, field: SourceField, key: string): MysqlSourceColumn { + const type = field.text ? 'varchar(64)' : 'int unsigned' + if ( + column === undefined || + (field.text ? column.type : column.type.replaceAll(/\(\d+\)/g, '')) !== type || + column.nullable !== (field.nullable ? 'YES' : 'NO') || + (column.extra !== '' && !(field.name === key && column.extra === 'auto_increment')) + ) + invalid('Unsupported snapshot global source column') + return column +} +function mysqlSourceText(previous: SourceText | undefined, column: MysqlSourceColumn): SourceText { + if (column.charset === null || column.collation === null) invalid('Unsupported snapshot global source text') + if (previous !== undefined && (previous.charset !== column.charset || previous.collation !== column.collation)) + invalid('Snapshot global source comparisons require matching text definitions') + return { charset: column.charset, collation: column.collation } +} +function validateMysqlSourceIndexes(parts: MysqlPart[], source: SourceDefinition): void { + if (!matchesMysqlIndex(parts, 'PRIMARY', [source.key])) invalid('Unsupported snapshot global source key') + if (source.name === 'proven_txs') return + const candidates = [...new Set(parts.map(part => part.name))] + const indexed = candidates.some(name => { + const index = parts.filter(part => part.name === name) + return ( + index[0]?.columnName === 'txid' && + index[0].direction === 'A' && + index[0].prefix === null && + (source.name === 'transactions' || (index.length === 1 && Number(index[0].nonUnique) === 0)) + ) + }) + if (!indexed) invalid('Snapshot global source requires complete transaction lookup indexes') +} +export async function validateMysqlSource(k: Knex): Promise { + let text: SourceText | undefined + await runInSeries(sources, async source => { + const [engines]: Array> = await k.raw( + 'SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [source.name] + ) + if (engines.length !== 1 || engines[0]?.engine !== 'InnoDB') + invalid('Snapshot global source requires transactional tables') + const [rules]: Array> = await k.raw( + 'SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?', + [source.name] + ) + // InnoDB cascades do not run the affected child's row triggers. Keep the + // standard RESTRICT/NO ACTION definitions; do not adopt a stale edge index. + const explicit = (rule: string): boolean => rule === 'RESTRICT' || rule === 'NO ACTION' + if (!Array.isArray(rules) || rules.some(rule => !explicit(rule.updateRule) || !explicit(rule.deleteRule))) + invalid('Snapshot global source requires explicit row mutations') + const [actual]: MysqlSourceColumn[][] = await k.raw( + 'SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [source.name] + ) + for (const field of source.fields) { + const column = mysqlSourceColumn( + actual.find(value => value.name === field.name), + field, + source.key + ) + if (field.text) text = mysqlSourceText(text, column) + } + validateMysqlSourceIndexes(await mysqlParts(k, source.name), source) + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts new file mode 100644 index 000000000..115cf486e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts @@ -0,0 +1,120 @@ +import type { Knex } from 'knex' +import { runInSeries } from '../../utility/runInSeries' +import { invalid, sources, type Table, type Index } from './snapshotGlobalIndexModel' +interface SqlitePart { + name: string + desc: number + coll: string + key: number +} +async function sqliteParts(k: Knex, name: string): Promise { + const rows: SqlitePart[] = await k.raw('PRAGMA index_xinfo(??)', [name]) + return rows.filter(row => row.key === 1) +} +export async function sqliteTable(k: Knex, table: Table, secondary: boolean): Promise { + const actual: Array<{ + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + hidden: number + }> = await k.raw('PRAGMA table_xinfo(??)', [table.name]) + const types = { + int: 'integer', + uint: 'integer', + biguint: 'bigint', + boolean: 'boolean' + } + if ( + !Array.isArray(actual) || + actual.length !== table.columns.length || + actual.some( + (column, i) => + column.name !== table.columns[i].name || + column.type.toLowerCase() !== types[table.columns[i].type] || + column.notnull !== 1 || + column.dflt_value !== null || + column.pk !== table.primary.indexOf(column.name) + 1 || + column.hidden !== 0 + ) + ) + return false + const indexes: Array<{ + name: string + unique: number + origin: string + partial: number + }> = await k.raw('PRAGMA index_list(??)', [table.name]) + if (indexes.some(index => index.unique !== 0 && (index.origin !== 'pk' || index.partial !== 0))) return false + const required: Index[] = secondary ? [...table.indexes] : [] + if (table.primary.length > 1) { + const primary = indexes.find(index => index.origin === 'pk') + if (primary === undefined) return false + required.push({ name: primary.name, columns: table.primary }) + } + for (const index of required) { + const found = indexes.find(value => value.name === index.name) + if (found === undefined || found.partial !== 0) return false + const parts = await sqliteParts(k, found.name) + if ( + parts.length !== index.columns.length || + parts.some((part, i) => part.name !== index.columns[i] || part.desc !== 0 || part.coll !== 'BINARY') + ) + return false + } + return true +} +async function sqliteTextOrder(k: Knex, table: string): Promise { + const indexes: Array<{ name: string; unique: number; partial: number }> = await k.raw('PRAGMA index_list(??)', [ + table + ]) + for (const index of indexes) { + if (index.partial !== 0 || (table === 'proven_tx_reqs' && index.unique !== 1)) continue + const parts = await sqliteParts(k, index.name) + if ( + parts[0]?.name !== 'txid' || + parts[0].desc !== 0 || + !['BINARY', 'NOCASE', 'RTRIM'].includes(parts[0].coll) || + (table === 'proven_tx_reqs' && parts.length !== 1) + ) + continue + const plan: Array<{ detail: string }> = await k.raw( + 'EXPLAIN QUERY PLAN SELECT txid FROM ?? INDEXED BY ?? ORDER BY txid LIMIT 1', + [table, index.name] + ) + if (!plan.some(step => step.detail.includes('TEMP B-TREE'))) return parts[0].coll + } + return invalid('Snapshot global source requires complete transaction lookup indexes') +} +export async function validateSqliteSource(k: Knex): Promise { + let collation: string | undefined + await runInSeries(sources, async source => { + const actual: Array<{ + name: string + type: string + notnull: number + pk: number + hidden: number + }> = await k.raw('PRAGMA table_xinfo(??)', [source.name]) + const primary = actual.filter(column => column.pk !== 0) + if (primary.length !== 1 || primary[0]?.name !== source.key || primary[0].pk !== 1) + invalid('Unsupported snapshot global source key') + for (const field of source.fields) { + const column = actual.find(value => value.name === field.name) + if ( + column === undefined || + column.type.toLowerCase() !== (field.text ? 'varchar(64)' : 'integer') || + column.hidden !== 0 || + (field.name !== source.key && column.notnull !== (field.nullable ? 0 : 1)) + ) + invalid('Unsupported snapshot global source column') + } + if (source.name !== 'proven_txs') { + const order = await sqliteTextOrder(k, source.name) + if (collation !== undefined && collation !== order) + invalid('Snapshot global source comparisons require matching text definitions') + collation = order + } + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.mysql.test.ts index 0892ff0fe..e7e777e89 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.mysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.mysql.test.ts @@ -26,6 +26,7 @@ function fixture(change: (kind: Kind, table: string, rows: Metadata) => unknown const triggers = new Map>() const edges: Edge[] = [] const guards = new Set() + let lastSourceCursor = -1 let journaled = false let progress: { id: number; afterRowId: number; complete: boolean | number } | undefined const columns: Record> = { @@ -169,10 +170,14 @@ function fixture(change: (kind: Kind, table: string, rows: Metadata) => unknown return [] } if (sql.startsWith('select `transactionId`, `userId`, `provenTxId`')) { - expect(sql).toContain('CASE WHEN octet_length(txid) <= 256 THEN txid END AS txid') - expect(sql).toContain('COALESCE(octet_length(txid),0) AS txidBytes') + const cursor = Number(values[2]) + expect(cursor).toBeGreaterThan(lastSourceCursor) + lastSourceCursor = cursor + expect(sql).toContain('CASE WHEN octet_length(txid) <= ? THEN txid END AS txid') + expect(sql).toContain('COALESCE(octet_length(txid),?) AS txidBytes') expect(sql).toContain('where `transactionId` > ? order by `transactionId` asc limit ? for update') - expect(values[1]).toBe(256) + expect(values.slice(0, 2)).toEqual([256, 0]) + expect(values[3]).toBe(256) return Array.from({ length: 257 }, (_, i) => ({ transactionId: i + 1, userId: (i % 2) + 1, @@ -180,8 +185,8 @@ function fixture(change: (kind: Kind, table: string, rows: Metadata) => unknown txid: i % 2 ? 'a' : null, txidBytes: i % 2 ? 1 : 0 })) - .filter(row => row.transactionId > Number(values[0])) - .slice(0, Number(values[1])) + .filter(row => row.transactionId > cursor) + .slice(0, Number(values[3])) } if (sql.startsWith('select `provenTxReqId`, `provenTxId`')) { expect(sql).toContain('from `proven_tx_reqs` where `txid` = ? limit ? lock in share mode') @@ -307,11 +312,105 @@ test('MySQL bootstrap uses current locks, bounded pages and idempotent reference } }) +test('MySQL trigger DDL preserves durable names, conditional updates and all current-read ownership bases', async () => { + const f = fixture() + try { + await install(f.k) + expect([...f.triggers].map(([name, row]) => [name, row.tableName, row.timing, row.event])).toEqual([ + ['snapshot_global_edge_delete', 'snapshot_global_edges', 'AFTER', 'DELETE'], + ['snapshot_global_edge_insert', 'snapshot_global_edges', 'AFTER', 'INSERT'], + ['snapshot_global_tx_delete', 'transactions', 'AFTER', 'DELETE'], + ['snapshot_global_tx_before_update', 'transactions', 'BEFORE', 'UPDATE'], + ['snapshot_global_req_delete', 'proven_tx_reqs', 'AFTER', 'DELETE'], + ['snapshot_global_req_before_update', 'proven_tx_reqs', 'BEFORE', 'UPDATE'], + ['snapshot_global_proof_delete', 'proven_txs', 'AFTER', 'DELETE'], + ['snapshot_global_proof_before_update', 'proven_txs', 'BEFORE', 'UPDATE'], + ['snapshot_global_proof_insert', 'proven_txs', 'AFTER', 'INSERT'], + ['snapshot_global_proof_after_update', 'proven_txs', 'AFTER', 'UPDATE'], + ['snapshot_global_tx_insert', 'transactions', 'AFTER', 'INSERT'], + ['snapshot_global_tx_after_update', 'transactions', 'AFTER', 'UPDATE'], + ['snapshot_global_req_insert', 'proven_tx_reqs', 'AFTER', 'INSERT'], + ['snapshot_global_req_after_update', 'proven_tx_reqs', 'AFTER', 'UPDATE'] + ]) + const body = (name: string): string => String(f.triggers.get('snapshot_global_' + name)?.body) + const transactionChange = + 'NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId)' + const requestChange = + 'NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId)' + for (const [name, condition] of [ + ['tx_before_update', transactionChange], + ['tx_after_update', transactionChange], + ['req_before_update', requestChange], + ['req_after_update', requestChange], + ['proof_before_update', 'NOT (OLD.provenTxId <=> NEW.provenTxId)'], + ['proof_after_update', 'NOT (OLD.provenTxId <=> NEW.provenTxId)'] + ]) { + expect(body(name)).toContain('IF ' + condition + ' THEN ') + expect(body(name)).toMatch(/ END IF; END$/) + } + for (const row of f.triggers.values()) expect(row.body).not.toContain('undefined') + for (const name of ['edge_delete', 'edge_insert', 'tx_delete', 'req_delete', 'proof_delete', 'proof_insert']) { + expect(body(name)).not.toContain(' END IF;') + } + expect(body('edge_insert')).toContain('ON DUPLICATE KEY UPDATE refs=snapshot_global_keys.refs+1;') + for (const name of ['tx_insert', 'tx_after_update']) { + const statement = body(name) + expect(statement).toContain('IF NEW.provenTxId IS NOT NULL THEN ') + expect(statement).toContain( + 'SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE;' + ) + expect(statement).toContain('IF requestedId IS NOT NULL THEN ') + expect(statement).toContain('IF requestedProof IS NOT NULL THEN ') + for (const tuple of [ + 'NEW.transactionId,0,1,NEW.provenTxId,NEW.userId', + 'NEW.transactionId,requestedId,0,requestedId,NEW.userId', + 'NEW.transactionId,requestedId,1,requestedProof,NEW.userId' + ]) + expect(statement).toContain( + 'VALUES (' + tuple + ') ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId;' + ) + for (const proof of ['NEW.provenTxId', 'requestedProof']) { + expect(statement).toContain( + 'INSERT INTO snapshot_global_guards (proofId,present) VALUES (' + + proof + + ',0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId;' + ) + expect(statement).toContain( + 'UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=' + + proof + + ';' + ) + } + } + for (const name of ['req_insert', 'req_after_update']) { + const statement = body(name) + expect(statement).toContain( + 'IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId;' + ) + expect(statement).toContain( + 'UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF;' + ) + expect( + statement.match(/ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId;/g) + ).toHaveLength(2) + } + for (const [name, presence] of [ + ['proof_delete', '0'], + ['proof_before_update', '0'], + ['proof_insert', '1'], + ['proof_after_update', '1'] + ]) + expect(body(name)).toContain('ON DUPLICATE KEY UPDATE present=' + presence + ';') + } finally { + await f.k.destroy() + } +}) + test.each(['CASCADE', 'SET NULL', 'SET DEFAULT'])( 'MySQL refuses implicit %s updates or deletes before DDL', async rule => { for (const field of ['updateRule', 'deleteRule']) { - const f = fixture((kind, _table, rows) => (kind === 'rules' ? [{ ...rows[0], [field]: rule }] : rows)) + const f = fixture((kind, _table, rows) => (kind === 'rules' ? [...rows, { ...rows[0], [field]: rule }] : rows)) try { await expect(install(f.k)).rejects.toThrow('requires explicit row mutations') await expect(remove(f.k)).rejects.toThrow('requires explicit row mutations') @@ -326,6 +425,7 @@ test.each(['CASCADE', 'SET NULL', 'SET DEFAULT'])( test.each([ ['engine', [], 'requires transactional tables'], ['engine', [{ engine: 'MyISAM' }], 'requires transactional tables'], + ['engine', [{ engine: 'InnoDB' }, { engine: 'InnoDB' }], 'requires transactional tables'], ['rules', null, 'requires explicit row mutations'], ['sourceColumns', [], 'Unsupported snapshot global source column'], ['indexes', null, 'Invalid snapshot global index metadata'], @@ -489,7 +589,7 @@ test('MySQL refuses nested migration transactions before any database work', asy } }) -test.each([{ rows: [] }, { rows: [{ engine: 'MyISAM' }] }])( +test.each([{ rows: [] }, { rows: [{ engine: 'MyISAM' }] }, { rows: [{ engine: 'InnoDB' }, { engine: 'InnoDB' }] }])( 'MySQL auxiliary engine metadata %j refuses adoption', async ({ rows: value }) => { const f = fixture((kind, table, rows) => (kind === 'engine' && table.startsWith('snapshot_') ? value : rows)) @@ -501,6 +601,46 @@ test.each([{ rows: [] }, { rows: [{ engine: 'MyISAM' }] }])( } ) +test('MySQL integer display widths preserve the supported source and auxiliary types', async () => { + const f = fixture((kind, _table, rows) => + kind === 'sourceColumns' || kind === 'columns' + ? rows.map(row => ({ ...row, type: String(row.type).replace(/^(bigint|int)\b/, '$1(10)') })) + : rows + ) + try { + await install(f.k) + f.setJournaled() + expect(await enabled(f.k)).toBe(true) + } finally { + await f.k.destroy() + } +}) + +test.each(['partial primary', 'extra unique', 'compound request lookup'])( + 'MySQL refuses %s index definitions even when their first column matches', + async altered => { + const f = fixture((kind, table, rows) => { + if (kind !== 'indexes') return rows + if (altered === 'partial primary' && table === 'snapshot_global_keys') + return rows.map((row, i) => (i === 1 ? { ...row, columnName: 'other' } : row)) + if (altered === 'extra unique' && table === 'snapshot_global_keys') + return [...rows, { ...rows[0], name: 'unexpected_unique', columnName: 'rowId' }] + if (altered === 'compound request lookup' && table === 'proven_tx_reqs') + return [...rows, { ...rows[1], columnName: 'provenTxReqId' }] + return rows + }) + try { + await expect(install(f.k)).rejects.toThrow( + altered === 'compound request lookup' + ? 'requires complete transaction lookup indexes' + : 'table definition mismatch' + ) + } finally { + await f.k.destroy() + } + } +) + test.each( [ null, diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.test.ts index fcdeaffa1..202568da9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.test.ts @@ -8,12 +8,30 @@ import { applyGlobalOperation } from '../../../test/utils/snapshotGlobalFixtures' import { - addSnapshotGlobalIndexes as install, + addSnapshotGlobalIndexes as installMigration, removeSnapshotGlobalIndexes as remove, readSnapshotGlobalIndexState as enabled, SNAPSHOT_GLOBAL_INDEX_MIGRATION as migration } from '../schema/snapshotGlobalIndexMigration' +// A migration attempt must advance every committed source page. Fail a stalled +// cursor at the query boundary so its work settles before fixture cleanup. +async function install(k: Knex): Promise { + let previous = -1 + const advancing = (query: { sql: string; bindings?: readonly unknown[] }): void => { + if (!query.sql.includes('from `transactions` where `transactionId` > ? order by')) return + const cursor = Number(query.bindings?.at(-2)) + expect(cursor).toBeGreaterThan(previous) + previous = cursor + } + k.on('query', advancing) + try { + await installMigration(k) + } finally { + k.off('query', advancing) + } +} + const databases: Knex[] = [] async function fixture(collation = 'BINARY') { const k = await minimalGlobalDatabase(collation) @@ -98,6 +116,57 @@ test.each(['BINARY', 'NOCASE', 'RTRIM'])( } ) +test.each([ + ['transactions', 'transactionId', 1], + ['proven_tx_reqs', 'provenTxReqId', 5], + ['proven_txs', 'provenTxId', 7] +] as const)('unchanged membership in %s does not rewrite auxiliary rows', async (table, key, id) => { + const k = await fixture() + await k.schema.alterTable(table, schema => { + void schema.string('payload') + }) + await install(k) + expect(await k('sqlite_master').where('type', 'trigger').pluck('name')).toEqual( + expect.arrayContaining([ + 'snapshot_global_tx_after_update', + 'snapshot_global_req_after_update', + 'snapshot_global_proof_after_update' + ]) + ) + await k('proven_txs').insert({ provenTxId: 7 }) + await k('proven_tx_reqs').insert({ provenTxReqId: 5, txid: 'a', provenTxId: 7 }) + await k('transactions').insert({ transactionId: 1, userId: 1, txid: 'a', provenTxId: 7 }) + const changed = async (): Promise => Number((await k.raw('SELECT total_changes() AS count'))[0].count) + const before = await changed() + await k(table).where(key, id).update({ payload: 'updated payload' }) + expect((await changed()) - before).toBe(1) + const afterPayload = await changed() + await k(table) + .where(key, id) + .update({ [key]: id }) + expect((await changed()) - afterPayload).toBe(1) + await expectGlobalMembership(k) +}) + +test.each(['transactions', 'proven_tx_reqs'])( + 'replacing an identical %s row preserves existing reference edges without double counting', + async table => { + const k = await fixture() + await install(k) + await k('proven_txs').insert({ provenTxId: 7 }) + await k('proven_tx_reqs').insert({ provenTxReqId: 5, txid: 'a', provenTxId: 7 }) + await k('transactions').insert({ transactionId: 1, userId: 1, txid: 'a', provenTxId: 7 }) + const before = await k('snapshot_global_keys').orderBy(['tableId', 'userId', 'rowId']) + if (table === 'transactions') { + await k.raw('INSERT OR REPLACE INTO transactions(transactionId,userId,txid,provenTxId) VALUES (1,1,?,7)', ['a']) + } else { + await k.raw('INSERT OR REPLACE INTO proven_tx_reqs(provenTxReqId,txid,provenTxId) VALUES (5,?,7)', ['a']) + } + await expectGlobalMembership(k) + expect(await k('snapshot_global_keys').orderBy(['tableId', 'userId', 'rowId'])).toEqual(before) + } +) + test('shared proofs remain owned until their final basis disappears and rolled-back changes leave no edges', async () => { const k = await fixture() await install(k) @@ -351,7 +420,7 @@ test('inconsistent SQLite primary-index metadata refuses adoption instead of ass const k = await fixture() await install(k) const raw = k.client.raw.bind(k.client) - jest.spyOn(k.client, 'raw').mockImplementation((...args: Parameters) => { + jest.spyOn(k.client, 'raw').mockImplementation((...args) => { const query = raw(...args) if (args[0] === 'PRAGMA index_list(??)' && Array.isArray(args[1]) && args[1][0] === 'snapshot_global_keys') { return query.then((rows: Array<{ origin: string }>) => @@ -362,3 +431,22 @@ test('inconsistent SQLite primary-index metadata refuses adoption instead of ass }) await expect(install(k)).rejects.toThrow('table definition mismatch') }) + +test.each([ + 'other integer NOT NULL PRIMARY KEY, present boolean NOT NULL', + 'proofId bigint NOT NULL PRIMARY KEY, present boolean NOT NULL', + 'proofId integer PRIMARY KEY, present boolean NOT NULL', + 'proofId integer NOT NULL PRIMARY KEY DEFAULT 0, present boolean NOT NULL', + 'proofId integer NOT NULL, present boolean NOT NULL PRIMARY KEY', + 'proofId integer NOT NULL PRIMARY KEY, present boolean NOT NULL GENERATED ALWAYS AS (1) STORED' +])('same-size altered auxiliary columns refuse resume, adoption and removal: %s', async definition => { + const k = await fixture() + await install(k) + await journal(k) + await k.schema.dropTable('snapshot_global_guards') + await k.raw(`CREATE TABLE snapshot_global_guards(${definition})`) + await expect(install(k)).rejects.toThrow('table definition mismatch') + await expect(enabled(k)).rejects.toThrow('table definition mismatch') + await expect(remove(k)).rejects.toThrow('table definition mismatch') + expect(await k.schema.hasTable('snapshot_global_keys')).toBe(true) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.test.ts index c81af5599..33ce0fa82 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.test.ts @@ -4,13 +4,15 @@ import { once } from 'node:events' import { StorageServer } from '../remoting/StorageServer' import { _tu } from '../../../test/utils/TestUtilsWalletStorage' import { PrivateKey } from '@bsv/sdk' +import { WalletError } from '../../sdk/WalletError' +import { WERR_NETWORK_CHAIN } from '../../sdk/WERR_errors' import { StorageIdb } from '../StorageIdb' import { StorageProvider } from '../StorageProvider' import { WalletStorageManager } from '../WalletStorageManager' import { EntitySyncState } from '../schema/entities/EntitySyncState' import { StorageClient } from '../remoting/StorageClient' import { validateSyncCheckpoint } from './syncCheckpoint' -import type { RequestSyncChunkArgs, SyncCheckpoint } from '../../sdk/WalletStorage.interfaces' +import type { ProcessSyncChunkResult, RequestSyncChunkArgs, SyncCheckpoint } from '../../sdk/WalletStorage.interfaces' async function makeStorage(): Promise { const storage = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) @@ -51,6 +53,84 @@ describe('compact sync checkpoints', () => { expect(() => validateSyncCheckpoint(c, { since: new Date() })).toThrow() }) + test.each([undefined, new Date('2026-01-01T00:00:00.000Z')])( + 'accepts a complete terminal reset with unchanged since %s and preserves other progress guards', + since => { + const reset = { ...checkpoint(), since } + const previous = { ...reset, offsets: reset.offsets.map((entry, index) => ({ ...entry, offset: index + 1 })) } + expect(validateSyncCheckpoint(reset, previous, true)).toEqual(reset) + expect(() => validateSyncCheckpoint(reset, previous)).toThrow('Invalid sync checkpoint') + expect(() => validateSyncCheckpoint(reset, previous, false)).toThrow('Invalid sync checkpoint') + expect(() => validateSyncCheckpoint({ ...reset, syncStateId: 2 }, previous, true)).toThrow() + expect(() => validateSyncCheckpoint({ ...reset, offsets: [] }, previous, true)).toThrow() + for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + const invalid = { + ...reset, + offsets: reset.offsets.map((entry, index) => (index === 0 ? { ...entry, offset } : entry)) + } + expect(() => validateSyncCheckpoint(invalid, previous, true)).toThrow() + } + const partial = { + ...reset, + offsets: reset.offsets.map((entry, index) => (index === 0 ? { ...entry, offset: 1 } : entry)) + } + expect(() => validateSyncCheckpoint(partial, previous, true)).toThrow('Invalid sync checkpoint') + const advanced = { ...previous, offsets: previous.offsets.map(entry => ({ ...entry, offset: entry.offset + 1 })) } + expect(validateSyncCheckpoint(advanced, previous, true)).toEqual(advanced) + if (since != null) { + expect(() => validateSyncCheckpoint({ ...reset, since: undefined }, previous, true)).toThrow() + expect(() => + validateSyncCheckpoint({ ...reset, since: new Date(since.getTime() - 1) }, previous, true) + ).toThrow() + } + } + ) + + test.each([false, undefined, 'true', 1])('rejects an offset reset without literal done: true (%s)', async done => { + const reset = { ...checkpoint(), since: new Date('2026-01-01T00:00:00.000Z') } + const previous = { ...reset, offsets: reset.offsets.map(entry => ({ ...entry, offset: 1 })) } + const client = new StorageClient({} as never, 'https://storage.example') + const rpc = jest.spyOn(client as never, 'rpcCall' as never) as jest.SpyInstance + rpc.mockResolvedValue({ done, inserts: 0, updates: 0, nextCheckpoint: reset }) + await expect(client.processSyncChunk(previous as RequestSyncChunkArgs, {} as never)).rejects.toThrow( + 'Invalid sync checkpoint' + ) + expect(rpc).toHaveBeenCalledTimes(1) + }) + + test.each(['foreign state', 'earlier timestamp', 'partial reset'])( + 'does not let a remote terminal reply bypass %s validation', + async invalid => { + const reset = { ...checkpoint(), since: new Date('2026-01-01T00:00:00.000Z') } + const previous = { ...reset, offsets: reset.offsets.map(entry => ({ ...entry, offset: 2 })) } + if (invalid === 'foreign state') reset.syncStateId = 2 + if (invalid === 'earlier timestamp') reset.since = new Date(reset.since.getTime() - 1) + if (invalid === 'partial reset') reset.offsets[0].offset = 1 + const client = new StorageClient({} as never, 'https://storage.example') + const rpc = jest.spyOn(client as never, 'rpcCall' as never) as jest.SpyInstance + rpc.mockResolvedValue({ done: true, inserts: 0, updates: 0, nextCheckpoint: reset }) + await expect(client.processSyncChunk(previous as RequestSyncChunkArgs, {} as never)).rejects.toThrow( + 'Invalid sync checkpoint' + ) + expect(rpc).toHaveBeenCalledTimes(1) + } + ) + + test.each([false, true])( + 'forwards a returned failure before validating its checkpoint (serialized: %s)', + async serialized => { + const original = new WERR_NETWORK_CHAIN('synthetic provider failure') + const error = serialized ? JSON.parse(WalletError.unknownToJson(original)) : original + const result = { error, done: true, inserts: 999, updates: 999, nextCheckpoint: { syncStateId: -1 } } + const client = new StorageClient({} as never, 'https://storage.example') + const rpc = jest.spyOn(client as never, 'rpcCall' as never) as jest.SpyInstance + rpc.mockResolvedValue(result) + await expect(client.processSyncChunk(checkpoint() as RequestSyncChunkArgs, {} as never)).resolves.toBe(result) + expect(result.error).toBe(error) + expect(rpc).toHaveBeenCalledTimes(1) + } + ) + test('checkpoint size stays bounded as the durable ID map grows and remains user scoped', async () => { const storage = await makeStorage() const identityKey = PrivateKey.fromRandom().toPublicKey().toString() @@ -190,77 +270,122 @@ describe('compact sync checkpoints', () => { } ) - test('backs up and restores every page through authenticated HTTP with compact committed progress', async () => { - const remote = await _tu.createSQLiteTestWallet({ databaseName: 'compactCheckpointHttp', dropAll: true }) - const source = await makeStorage() - const restored = await makeStorage() - const server = new StorageServer(remote.activeStorage, { - port: 0, - wallet: remote.wallet, - monetize: false, - logRpcRequests: false, - calculateRequestPrice: async () => 0 - }) - let client: StorageClient | undefined - try { - server.start() - if (!server.server.listening) await once(server.server, 'listening') - const address = server.server.address() - if (address == null || typeof address === 'string') throw new Error('test server did not bind') - client = new StorageClient(remote.wallet, `http://localhost:${address.port}`, { binaryRequests: true }) - const identityKey = remote.identityKey - const manager = new WalletStorageManager(identityKey, source) - await manager.makeAvailable() - const { user } = await source.findOrInsertUser(identityKey) - for (let i = 0; i < 37; i++) await source.findOrInsertTxLabel(user.userId, `http fixture ${i}`) - const read = source.getSyncChunk.bind(source) - jest.spyOn(source, 'getSyncChunk').mockImplementation(args => read({ ...args, maxItems: 5 })) - const checkpoints = jest.spyOn(client, 'getSyncCheckpoint') - const fullStates = jest.spyOn(client, 'findOrInsertSyncStateAuth') - const backup = await manager.syncToWriter({ identityKey }, client) - expect(backup.inserts).toBeGreaterThanOrEqual(37) - expect(checkpoints).toHaveBeenCalledTimes(1) - expect(fullStates).not.toHaveBeenCalled() - const remoteRead = remote.activeStorage.getSyncChunk.bind(remote.activeStorage) - jest.spyOn(remote.activeStorage, 'getSyncChunk').mockImplementation(args => remoteRead({ ...args, maxItems: 5 })) - const restoreManager = new WalletStorageManager(identityKey, restored) - await restoreManager.makeAvailable() - await restoreManager.syncFromReader(identityKey, client) - const { user: target } = await restored.findOrInsertUser(identityKey) - expect(await restored.countTxLabels({ partial: { userId: target.userId } })).toBe(37) - const noChange = await restoreManager.syncFromReader(identityKey, client) - expect(noChange.inserts).toBe(0) - expect(noChange.updates).toBe(0) - // Exercise real authenticated binary uploads independently of entity merge rules. - const bytes = Array.from({ length: 4096 }, (_, i) => i % 256) - const process = jest.spyOn(remote.activeStorage, 'processSyncChunk').mockResolvedValueOnce({ - done: true, - inserts: 0, - updates: 0, - maxUpdated_at: undefined + test.each([false, true])( + 'backs up unchanged and restores through authenticated HTTP with binary requests %s', + async binaryRequests => { + const remote = await _tu.createSQLiteTestWallet({ + databaseName: `compactCheckpointHttp-${binaryRequests}`, + dropAll: true }) - const authClient = Reflect.get(client, 'authClient') as { fetch: typeof fetch } - const fetchSpy = jest.spyOn(authClient, 'fetch') - await client.processSyncChunk( - { identityKey } as RequestSyncChunkArgs, - { - outputs: [{ created_at: new Date(), updated_at: new Date(), lockingScript: bytes }] - } as never - ) - expect(process.mock.calls[0][1].outputs?.[0].lockingScript).toEqual(bytes) - const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)) - expect(body.params[1].outputs[0].lockingScript.$bsvBinary).toBe('base64') - expect(JSON.stringify(body).length).toBeLessThan(JSON.stringify(bytes).length) - } finally { - await client?.destroy() - await server.close() - await remote.wallet.destroy() - await source.destroy() - await restored.destroy() - await source.dropAllData() - await restored.dropAllData() - } - }, 60000) + const source = await makeStorage() + const restored = await makeStorage() + const server = new StorageServer(remote.activeStorage, { + port: 0, + wallet: remote.wallet, + monetize: false, + logRpcRequests: false, + calculateRequestPrice: async () => 0 + }) + let client: StorageClient | undefined + try { + server.start() + if (!server.server.listening) await once(server.server, 'listening') + const address = server.server.address() + if (address == null || typeof address === 'string') throw new Error('test server did not bind') + client = new StorageClient(remote.wallet, `http://localhost:${address.port}`, { binaryRequests }) + const identityKey = remote.identityKey + const manager = new WalletStorageManager(identityKey, source) + await manager.makeAvailable() + const { user } = await source.findOrInsertUser(identityKey) + for (let i = 0; i < 37; i++) await source.findOrInsertTxLabel(user.userId, `http fixture ${i}`) + const read = source.getSyncChunk.bind(source) + jest.spyOn(source, 'getSyncChunk').mockImplementation(args => read({ ...args, maxItems: 5 })) + const checkpoints = jest.spyOn(client, 'getSyncCheckpoint') + const fullStates = jest.spyOn(client, 'findOrInsertSyncStateAuth') + const committedPages = jest.spyOn(remote.activeStorage, 'processSyncChunk') + const backup = await manager.syncToWriter({ identityKey }, client) + expect(backup.inserts).toBeGreaterThanOrEqual(37) + expect(checkpoints).toHaveBeenCalledTimes(1) + expect(fullStates).not.toHaveBeenCalled() + const unchanged = await manager.syncToWriter({ identityKey }, client) + expect(unchanged).toMatchObject({ inserts: 0, updates: 0 }) + const terminalArgs = committedPages.mock.calls.at(-1)![0] + const terminalReply = (await committedPages.mock.results.at(-1)!.value) as ProcessSyncChunkResult + expect(terminalReply.done).toBe(true) + expect(terminalArgs.offsets.some(entry => entry.offset > 0)).toBe(true) + expect(terminalReply.nextCheckpoint?.offsets.every(entry => entry.offset === 0)).toBe(true) + expect(terminalReply.nextCheckpoint?.since?.getTime()).toBe(new Date(terminalArgs.since!).getTime()) + const upload = client.processSyncChunk.bind(client) + const lostReply = jest.spyOn(client, 'processSyncChunk').mockImplementation(async (args, chunk) => { + const result = await upload(args, chunk) + if (result.done) throw new Error('synthetic lost terminal acknowledgement') + return result + }) + await expect(manager.syncToWriter({ identityKey }, client)).rejects.toThrow( + 'synthetic lost terminal acknowledgement' + ) + lostReply.mockRestore() + await expect(manager.syncToWriter({ identityKey }, client)).resolves.toMatchObject({ inserts: 0, updates: 0 }) + const providerFailure = new WERR_NETWORK_CHAIN('synthetic remote terminal failure') + committedPages.mockResolvedValueOnce({ + error: JSON.parse(WalletError.unknownToJson(providerFailure)), + done: true, + inserts: 999, + updates: 999, + maxUpdated_at: new Date(), + nextCheckpoint: { syncStateId: -1 } as SyncCheckpoint + }) + await expect(manager.syncToWriter({ identityKey }, client)).rejects.toThrow(providerFailure.message) + await expect(manager.syncToWriter({ identityKey }, client)).resolves.toMatchObject({ inserts: 0, updates: 0 }) + const { user: remoteUser } = await remote.activeStorage.findOrInsertUser(identityKey) + expect(await remote.activeStorage.countTxLabels({ partial: { userId: remoteUser.userId } })).toBe(37) + const remoteRead = remote.activeStorage.getSyncChunk.bind(remote.activeStorage) + jest + .spyOn(remote.activeStorage, 'getSyncChunk') + .mockImplementation(args => remoteRead({ ...args, maxItems: 5 })) + const restoreManager = new WalletStorageManager(identityKey, restored) + await restoreManager.makeAvailable() + await restoreManager.syncFromReader(identityKey, client) + const { user: target } = await restored.findOrInsertUser(identityKey) + expect(await restored.countTxLabels({ partial: { userId: target.userId } })).toBe(37) + const noChange = await restoreManager.syncFromReader(identityKey, client) + expect(noChange.inserts).toBe(0) + expect(noChange.updates).toBe(0) + committedPages.mockRestore() + if (binaryRequests) { + // Exercise real authenticated binary uploads independently of entity merge rules. + const bytes = Array.from({ length: 4096 }, (_, i) => i % 256) + const process = jest.spyOn(remote.activeStorage, 'processSyncChunk').mockResolvedValueOnce({ + done: true, + inserts: 0, + updates: 0, + maxUpdated_at: undefined + }) + const authClient = Reflect.get(client, 'authClient') as { fetch: typeof fetch } + const fetchSpy = jest.spyOn(authClient, 'fetch') + await client.processSyncChunk( + { identityKey } as RequestSyncChunkArgs, + { + outputs: [{ created_at: new Date(), updated_at: new Date(), lockingScript: bytes }] + } as never + ) + expect(process.mock.calls[0][1].outputs?.[0].lockingScript).toEqual(bytes) + const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)) + expect(body.params[1].outputs[0].lockingScript.$bsvBinary).toBe('base64') + expect(JSON.stringify(body).length).toBeLessThan(JSON.stringify(bytes).length) + } + } finally { + await client?.destroy() + await server.close() + await remote.wallet.destroy() + await source.destroy() + await restored.destroy() + await source.dropAllData() + await restored.dropAllData() + } + }, + 60000 + ) test('rejects replay of a staged page after its checkpoint has already committed', async () => { const writer = await makeStorage() @@ -268,14 +393,31 @@ describe('compact sync checkpoints', () => { try { const { user } = await writer.findOrInsertUser(identityKey) const checkpoint = await writer.getSyncCheckpoint({ identityKey }, 'source', 'source') - const args = { ...checkpoint, identityKey, fromStorageIdentityKey: 'source', - toStorageIdentityKey: writer.getSettings().storageIdentityKey, maxItems: 1, maxRoughSize: 1024, - requireMatchingCheckpoint: true } + const args = { + ...checkpoint, + identityKey, + fromStorageIdentityKey: 'source', + toStorageIdentityKey: writer.getSettings().storageIdentityKey, + maxItems: 1, + maxRoughSize: 1024, + requireMatchingCheckpoint: true + } const now = new Date() - const chunk = { userIdentityKey: identityKey, fromStorageIdentityKey: 'source', + const chunk = { + userIdentityKey: identityKey, + fromStorageIdentityKey: 'source', toStorageIdentityKey: args.toStorageIdentityKey, - outputTags: [{ outputTagId: 1234, userId: user.userId, created_at: now, updated_at: now, - tag: 'staged replay fixture', isDeleted: false }] } + outputTags: [ + { + outputTagId: 1234, + userId: user.userId, + created_at: now, + updated_at: now, + tag: 'staged replay fixture', + isDeleted: false + } + ] + } await expect(writer.processSyncChunk(args, chunk)).resolves.toMatchObject({ inserts: 1 }) await expect(writer.processSyncChunk(args, chunk)).rejects.toThrow('checkpoint changed') const saved = await writer.getSyncCheckpoint({ identityKey }, 'source', 'source') diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts index 8b3e2528e..c76dfc5c0 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncCheckpoint.ts @@ -20,7 +20,11 @@ function invalidCheckpoint(): never { } /** Validate remote progress and return only the fields permitted to advance a sync. */ -export function validateSyncCheckpoint(value: SyncCheckpoint, previous?: Partial): SyncCheckpoint { +export function validateSyncCheckpoint( + value: SyncCheckpoint, + previous?: Partial, + completed = false +): SyncCheckpoint { if ( value == null || typeof value !== 'object' || @@ -43,7 +47,11 @@ export function validateSyncCheckpoint(value: SyncCheckpoint, previous?: Partial if (!Number.isFinite(since.getTime())) invalidCheckpoint() } if (previous?.since != null && (since == null || since < previous.since)) invalidCheckpoint() + // The legacy terminal page resets every offset, including when equal-time + // rows leave the high-water timestamp unchanged. Other retreats stay invalid. + const completedReset = completed && offsets.every(entry => entry.offset === 0) if ( + !completedReset && previous != null && since?.getTime() === previous.since?.getTime() && previous.offsets != null && diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts index aabad6ba9..e786fdec1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts @@ -79,6 +79,54 @@ test('foreground reads and writes complete while source I/O waits, with bounded expect((await manager.syncFromReaderResumable(identityKey, reader)).inserts).toBe(0) }) +test.each(['earlier timestamp', 'missing timestamp', 'foreign state', 'partial reset'])( + 'rejects a terminal %s reply and resumes from the actual durable checkpoint', + async invalid => { + const { reader, writer, manager } = await fixture(3) + await manager.syncFromReaderResumable(identityKey, reader, { maxItems: 2 }) + const prepare = writer.prepareSyncChunk.bind(writer) + let terminalReplies = 0 + const events: SyncSessionProgress[] = [] + const altered = jest.spyOn(writer, 'prepareSyncChunk').mockImplementation(async (args, chunk) => { + const apply = await prepare(args, chunk) + return async () => { + const result = await apply() + if (result.done) { + terminalReplies++ + const checkpoint = result.nextCheckpoint! + expect(args.since).toBeDefined() + expect(checkpoint.since).toEqual(args.since) + if (invalid === 'earlier timestamp') checkpoint.since = new Date(args.since!.getTime() - 1) + if (invalid === 'missing timestamp') checkpoint.since = undefined + if (invalid === 'foreign state') checkpoint.syncStateId++ + if (invalid === 'partial reset') checkpoint.offsets[0].offset = 1 + } + return result + } + }) + await expect( + manager.syncFromReaderResumable(identityKey, reader, { + maxItems: 2, + onProgress: event => events.push(event) + }) + ).rejects.toThrow('Invalid sync checkpoint') + expect(terminalReplies).toBe(1) + expect(events.some(event => event.state === 'completed')).toBe(false) + altered.mockRestore() + const settings = reader.getSettings() + const durable = await writer.getSyncCheckpoint({ identityKey }, settings.storageIdentityKey, settings.storageName) + expect(durable.since).toBeDefined() + expect(durable.offsets.every(entry => entry.offset === 0)).toBe(true) + await expect(manager.syncFromReaderResumable(identityKey, reader, { maxItems: 2 })).resolves.toMatchObject({ + status: 'completed', + inserts: 0, + updates: 0, + checkpoint: durable + }) + expect(await writer.countTxLabels({ partial: {} })).toBe(3) + } +) + test('cancellation during a read discards the late page without starting a write', async () => { const { reader, writer, manager } = await fixture() const entered = deferred() diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts index 40f4cb862..da98da1ab 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.ts @@ -117,8 +117,7 @@ async function committedCheckpoint( reply: ProcessSyncChunkResult ): Promise { if (reply.nextCheckpoint == null) return requestCheckpoint(await session.loadRequest()) - const expected = reply.done ? { syncStateId: args.syncStateId } : args - return validateSyncCheckpoint(reply.nextCheckpoint, expected) + return validateSyncCheckpoint(reply.nextCheckpoint, args, reply.done === true) } /** One page in flight; resume always starts with the destination's durable checkpoint. */ diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index e6db197e3..4077bcc44 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -22,6 +22,10 @@ const plans = new Map([ ['src/storage/schema/snapshotRelationIndexMigration.ts', 'relation-index'], ['src/storage/schema/snapshotCertificateIndexMigration.ts', 'certificate-index'], ['src/storage/schema/snapshotGlobalIndexMigration.ts', 'global-index'], + ['src/storage/schema/snapshotGlobalIndexModel.ts', 'global-index'], + ['src/storage/schema/snapshotGlobalIndexMysql.ts', 'global-mysql'], + ['src/storage/schema/snapshotGlobalIndexSqlite.ts', 'global-sqlite'], + ['src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'global-bootstrap'], ['src/storage/schema/snapshotGlobalIndexTriggers.ts', 'global-triggers'], ['src/storage/StorageKnex.ts', 'storage'], ['src/storage/StorageProvider.ts', 'storage'] diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index cb445c25f..4138db90d 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -267,6 +267,9 @@ for (const [id, expected, fallback] of [ 'relation-index', 'certificate-index', 'global-index', + 'global-mysql', + 'global-sqlite', + 'global-bootstrap', 'global-triggers', 'storage' ], diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index e0e8eb304..dc0e02daf 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -227,3 +227,10 @@ test('additional package-relative fixture inputs select their target without rep ) } }) + +test('retained snapshot mutation execution recycles workers while other wallet defaults remain intact', () => { + const targets = buildMutationTargets(REPOSITORY_ROOT) + assert.equal(targets['wallet-retained-snapshot'].runnerOptions.maxTestRunnerReuse, 8) + assert.equal(targets['wallet-snapshot-archive'].runnerOptions.maxTestRunnerReuse, undefined) + assert.equal(targets['wallet-snapshot-remote-http'].runnerOptions.maxTestRunnerReuse, undefined) +}) From 089a1855fd48445e7133b72f415a76cca87aceb7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 20:55:58 -0700 Subject: [PATCH 081/127] fix(wallet): rebuild SQLite snapshot indexes with conflict-safe observers --- docs/guides/wallet-sync-reliability.md | 45 +++ docs/reference/package-api-migrations.md | 74 ++--- governance/mutation-testing/targets.mjs | 44 ++- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 7 + packages/wallet/wallet-toolbox/README.md | 6 + .../wallet-toolbox/src/storage/StorageKnex.ts | 6 + ...orageKnexMigrationFailure.security.test.ts | 46 ++- .../src/storage/schema/KnexMigrations.ts | 9 + .../schema/snapshotGlobalIndexSqlite.ts | 5 +- .../storage/schema/snapshotSqliteIdentity.ts | 130 ++++++++ .../schema/snapshotSqliteIndexBootstrap.ts | 243 ++++++++++++++ .../schema/snapshotSqliteIndexGeneration.ts | 287 ++++++++++++++++ .../schema/snapshotSqliteIndexMigration.ts | 56 ++++ .../schema/snapshotSqliteIndexRetirement.ts | 59 ++++ .../schema/snapshotSqliteIndexState.ts | 35 ++ .../schema/snapshotSqliteLegacyOwnership.ts | 76 +++++ .../schema/snapshotSqliteMembership.ts | 246 ++++++++++++++ .../ConcurrentSnapshotArchiveSource.test.ts | 2 +- .../snapshot/KnexWalletReadSnapshot.test.ts | 10 +- .../snapshot/KnexWalletReadSnapshot.ts | 128 ++++--- ...shotCertificateIndexes.integration.test.ts | 3 +- .../SnapshotGlobalIndexes.integration.test.ts | 3 +- ...SnapshotProfileIndexes.integration.test.ts | 3 +- .../SnapshotProfileIndexes.migration.test.ts | 7 +- ...napshotRelationIndexes.integration.test.ts | 3 +- .../SnapshotSqliteActualSchema.test.ts | 128 +++++++ .../SnapshotSqliteAllFamilies.test.ts | 88 +++++ .../snapshot/SnapshotSqliteFileWal.test.ts | 51 +++ .../snapshot/SnapshotSqliteGeneration.test.ts | 174 ++++++++++ .../SnapshotSqliteGenerationCost.test.ts | 208 ++++++++++++ .../SnapshotSqliteGenerationMigration.test.ts | 56 ++++ .../SnapshotSqliteGenerationReaders.test.ts | 142 ++++++++ .../SnapshotSqliteGenerationRegistry.test.ts | 116 +++++++ ...SnapshotSqliteGenerationRetirement.test.ts | 153 +++++++++ .../snapshot/SnapshotSqliteIdentity.test.ts | 312 ++++++++++++++++++ .../KnexSnapshotArchiveCapture.test.ts | 8 +- .../archive/KnexSnapshotArchiveClosure.ts | 9 +- .../archive/KnexSnapshotArchiveSource.ts | 24 +- .../archive/KnexSnapshotArchiveStore.test.ts | 2 +- .../archive/SnapshotArchiveHttp.test.ts | 2 +- .../test/storage/snapshotArchiveCrash.cjs | 2 + .../test/storage/snapshotArchiveMysql.cjs | 4 +- .../storage/snapshotCertificateIndexCrash.cjs | 7 +- .../test/storage/snapshotGlobalIndexCrash.cjs | 5 +- .../storage/snapshotHistoricalMigrations.cjs | 16 + .../storage/snapshotProfileIndexCrash.cjs | 7 +- .../storage/snapshotRelationIndexCrash.cjs | 7 +- .../storage/snapshotSqliteGenerationCrash.cjs | 273 +++++++++++++++ .../utils/snapshotHistoricalMigrations.ts | 21 ++ .../test/utils/snapshotSqliteFixtures.ts | 157 +++++++++ .../utils/snapshotSqliteIdentityFixture.ts | 26 ++ .../utils/snapshotSqliteMaintenanceFixture.ts | 24 ++ scripts/mutation-partitions.mjs | 8 + scripts/mutation-partitions.test.mjs | 4 + scripts/mutation-testing.test.mjs | 8 +- specs/wallet/sync-portability-program.md | 12 + 57 files changed, 3457 insertions(+), 134 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentity.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexBootstrap.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexMigration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexRetirement.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexState.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteMembership.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteActualSchema.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteAllFamilies.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteFileWal.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGeneration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationCost.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationMigration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationReaders.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRetirement.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteIdentity.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotHistoricalMigrations.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotSqliteGenerationCrash.cjs create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotHistoricalMigrations.ts create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotSqliteFixtures.ts create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotSqliteIdentityFixture.ts create mode 100644 packages/wallet/wallet-toolbox/test/utils/snapshotSqliteMaintenanceFixture.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 5dd06925a..d20951279 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -421,6 +421,51 @@ acceptance. Commit ordering, tombstones, primary reconciliation, nonblocking IndexedDB, remote destinations, streaming/staged portability and full system acceptance remain open. Reader advertisement stays disabled. +### SQLite conflict-safe index generation (unpublished candidate) + +Migration `2026-10-02-001 repair snapshot SQLite conflict maintenance` replaces +SQLite auxiliary index maintenance with metadata-bound conflict witnesses. +SQLite REPLACE can remove a displaced row without invoking its deletion trigger; +the new BEFORE observers retain displaced IDs and owners before the source write. +AFTER observers reconcile the actual source state and preserve independent +relation, certificate and proof-reference memberships. Supported unique keys use +their existing BINARY, NOCASE or RTRIM comparison; NULL values retain SQLite's +unique-key behavior. No-op and unrelated payload updates avoid auxiliary writes. +Standard rows, indexes, legacy OFFSET order and portable/cursor bytes stay intact. +MySQL continues to use its existing maintenance. + +Installation validates the complete prior migrations, source definitions and +owned auxiliary objects, then atomically installs a fresh v2 generation and +invalidates the previous progress states. Twelve source streams copy at most +256 rows per transaction with typed durable positions. Independent writes, +including inserts below the saved cursor, remain observed throughout the copy. +Each completed page yields before the next transaction. Once the copy completes, +obsolete owned tables retire in batches of at most 256 physical rows; only empty +tables are dropped. Source tables are never retired. The hidden SQLite row +identity bounds retirement even when several index families share one logical ID. + +Run `migrate()` explicitly, exclude concurrent migrators, and preserve owned +schema/progress after interruption. Recover a stale Knex lock only after proving +the migrator stopped. The real migrator publishes its journal after copy and +retirement; partial state uses source-query fallback in new readers. Complete +journal/progress state selects v2 indexes inside the same retained view as all +pages. An already-pinned WAL reader retains its original view across retirement. +Older binaries cannot adopt invalidated progress: do not downgrade their snapshot +implementation against this schema. Ordinary migration rollback refuses without +deleting source rows; use a separately designed forward migration. The explicit +`dropAllData()` API still deletes all wallet data, including an unpublished +partial generation. It is not a recovery or downgrade operation. + +Qualification includes generated conflict schedules, actual wallet schemas, +independent WAL writers, retained ordinary/archive readers and real registered +migration process loss through installation, copying, retirement and publication. +The populated diagnostic uses 13,000 small synthetic source rows and verifies +bounded retirement and six indexed late-page shapes. It does not establish +large-wallet latency, internal SQLite row visits or an overall storage quota. +Freed SQLite pages may remain allocated for reuse; physical file shrink is not +promised. Complete exact-head native, mutation, platform and hosted gates remain +required. Reader advertisement and the full #544 acceptance status are unchanged. + ## Durable local SQL sync and ordinary backup With the version-one migration applied, supported local SQL providers use these diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index b58e23884..81d1ba647 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. -- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. +- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. +- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index a2ab18313..85518bacd 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -416,7 +416,13 @@ export function buildMutationTargets(repositoryRoot) { additionalInputs: [ 'test/utils/snapshotRelationFixtures.ts', 'test/utils/snapshotCertificateFixtures.ts', - 'test/utils/snapshotGlobalFixtures.ts' + 'test/utils/snapshotGlobalFixtures.ts', + 'test/utils/snapshotHistoricalMigrations.ts', + 'test/utils/snapshotSqliteFixtures.ts', + 'test/utils/snapshotSqliteIdentityFixture.ts', + 'test/utils/snapshotSqliteMaintenanceFixture.ts', + 'test/storage/snapshotHistoricalMigrations.cjs', + 'test/storage/snapshotSqliteGenerationCrash.cjs' ], propertyTest: 'packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts', @@ -432,6 +438,14 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/schema/snapshotGlobalIndexSqlite.ts', 'src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'src/storage/schema/snapshotGlobalIndexTriggers.ts', + 'src/storage/schema/snapshotSqliteIdentity.ts', + 'src/storage/schema/snapshotSqliteMembership.ts', + 'src/storage/schema/snapshotSqliteIndexGeneration.ts', + 'src/storage/schema/snapshotSqliteIndexBootstrap.ts', + 'src/storage/schema/snapshotSqliteIndexState.ts', + 'src/storage/schema/snapshotSqliteIndexRetirement.ts', + 'src/storage/schema/snapshotSqliteLegacyOwnership.ts', + 'src/storage/schema/snapshotSqliteIndexMigration.ts', sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', @@ -439,6 +453,13 @@ export function buildMutationTargets(repositoryRoot) { 'override supportsRetainedReadSnapshot(): boolean', 'private async readMySQLSnapshot' ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override async dropAllData(): Promise', + 'override async transaction' + ), sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', @@ -447,15 +468,22 @@ export function buildMutationTargets(repositoryRoot) { 'protected supportsActionBatchPersistence(): boolean' ) ], - ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/*.test.ts'], { - maxTestRunnerReuse: 8, - config: { - moduleNameMapper: { - '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), - '^(\\.{1,2}/.*)\\.js$': '$1' + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/snapshot/*.test.ts', + '/src/storage/__test/StorageKnexMigrationFailure.security.test.ts' + ], + { + maxTestRunnerReuse: 8, + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } } } - }) + ) }, 'wallet-snapshot-archive': { packageDirectory: 'packages/wallet/wallet-toolbox', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index fa158118e..512be1683 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved.", - "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off." + "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged.", + "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 07e31dc2a..5c9d5f134 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,13 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Repair SQLite replacement maintenance with a new auxiliary generation, durable + displaced-owner witnesses, bounded resumable source copying and physical-row + retirement. Preserve source tables, profile/reference semantics and pinned + ordinary/archive views. Apply the explicit forward migration; normal downgrade + refuses, while explicit full-data deletion remains supported. Complete native, + mutation, larger-wallet and hosted acceptance remain required. + - Accept a valid all-zero terminal offset reset when an unchanged backup keeps the same timestamp. Preserve remote state binding, monotonic timestamps and nonterminal/partial-reset checks in HTTP and resumable sessions. Repeated diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 87faf9b96..f5a1d4ce5 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -126,6 +126,12 @@ Direct and request-based proof references remain independent; removing the last reference collects its auxiliary presence guard. Standard rows, text comparisons, indexes and cursor/portable bytes remain unchanged. See the [global migration and recovery contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#auxiliary-global-proofrequest-indexes-unpublished-candidate). +The additive SQLite conflict-repair migration installs metadata-bound witnesses, +rebuilds all four index families in bounded resumable pages, and retires obsolete +auxiliary rows without changing standard wallet data. Reader adoption follows +journal publication inside the pinned view. Preserve partial state for recovery; +ordinary downgrade refuses, while explicit `dropAllData()` retains its destructive +contract. See the [SQLite generation migration contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#sqlite-conflict-safe-index-generation-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index f6f048390..8ca58b65c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,3 +1,5 @@ +import { dropGenerationForDataDeletion } from './schema/snapshotSqliteIndexMigration' +import { migration as SNAPSHOT_SQLITE_INDEX_MIGRATION } from './schema/snapshotSqliteIndexState' import { SnapshotResourceLimitError } from './snapshot/SnapshotResourceLimitError' import { readGuardedSnapshotArchive, recoverSnapshotArchiveGuards } from './snapshot/archive/SnapshotArchiveGuard' import type { SnapshotArchiveRequestOwner } from './snapshot/archive/SnapshotArchiveRequest' @@ -2038,6 +2040,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide override async dropAllData(): Promise { // Only using migrations to migrate down, don't need valid properties for settings table. const migrationSource = new KnexMigrations('test', '', '', 1024) + migrationSource.migrations[SNAPSHOT_SQLITE_INDEX_MIGRATION].down = dropGenerationForDataDeletion // Check if this is a SQLite database by looking at the Knex client config const clientName = (this.knex.client as { config?: { client?: string } }).config?.client ?? '' @@ -2060,6 +2063,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } try { + // An interrupted, unpublished generation has no journal entry to roll back. + // Explicit full deletion still removes its owned auxiliary schema first. + await dropGenerationForDataDeletion(this.knex) for (let i = 0; i < count; i++) { const version = await this.knex.migrate.currentVersion(config) if (version === 'none') return diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnexMigrationFailure.security.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnexMigrationFailure.security.test.ts index 913e69db0..72caa4ff5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnexMigrationFailure.security.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnexMigrationFailure.security.test.ts @@ -1,4 +1,11 @@ import { StorageKnex } from '../StorageKnex' +import { KnexMigrations } from '../schema/KnexMigrations' + +function absentGeneration() { + return jest.fn(async (scope: (trx: object) => Promise) => { + await scope({ schema: { hasTable: jest.fn().mockResolvedValue(false) } }) + }) +} function storageWithKnex(knex: object): StorageKnex { return { knex, chain: 'test' } as unknown as StorageKnex @@ -24,6 +31,7 @@ describe('StorageKnex migration failure boundaries', () => { test('dropAllData stops only at the explicit empty-schema state', async () => { const knex = { client: { config: { client: 'better-sqlite3' } }, + transaction: absentGeneration(), raw: jest.fn().mockResolvedValue(undefined), migrate: { currentVersion: jest.fn().mockResolvedValueOnce('202609170001').mockResolvedValueOnce('none'), @@ -46,6 +54,7 @@ describe('StorageKnex migration failure boundaries', () => { ])('propagates when rollback %s and always restores SQLite foreign keys', async (_name, down) => { const knex = { client: { config: { client: 'better-sqlite3' } }, + transaction: absentGeneration(), raw: jest.fn().mockResolvedValue(undefined), migrate: { currentVersion: jest.fn().mockResolvedValue('202609170001'), @@ -53,13 +62,16 @@ describe('StorageKnex migration failure boundaries', () => { } } - await expect(StorageKnex.prototype.dropAllData.call(storageWithKnex(knex))).rejects.toThrow() + await expect(StorageKnex.prototype.dropAllData.call(storageWithKnex(knex))).rejects.toThrow( + _name === 'throws' ? 'rollback failed' : 'database migration rollback returned no result' + ) expect(knex.raw).toHaveBeenLastCalledWith('PRAGMA foreign_keys = ON;') }) test('migrate restores SQLite foreign keys when migration fails', async () => { const knex = { client: { config: { client: 'better-sqlite3' } }, + transaction: absentGeneration(), raw: jest.fn().mockResolvedValue(undefined), migrate: { latest: jest.fn().mockRejectedValue(new Error('migration failed')), @@ -73,3 +85,35 @@ describe('StorageKnex migration failure boundaries', () => { expect(knex.raw).toHaveBeenLastCalledWith('PRAGMA foreign_keys = ON;') }) }) + +test('dropAllData bounds a migrator that never reaches the empty schema', async () => { + const database = { + client: { config: { client: 'better-sqlite3' } }, + transaction: absentGeneration(), + raw: jest.fn().mockResolvedValue(undefined), + migrate: { + currentVersion: jest.fn().mockResolvedValue('unfinished'), + down: jest.fn().mockResolvedValue([1, ['fixture']]) + } + } + await expect(StorageKnex.prototype.dropAllData.call(storageWithKnex(database))).rejects.toThrow( + 'database migration rollback did not reach the empty schema' + ) + expect(database.migrate.down).toHaveBeenCalledTimes( + Object.keys(new KnexMigrations('test', '', '', 1024).migrations).length + ) + expect(database.raw).toHaveBeenLastCalledWith('PRAGMA foreign_keys = ON;') +}) + +test('MySQL full deletion does not issue SQLite generation operations', async () => { + const database = { + client: { config: { client: 'mysql2' } }, + transaction: jest.fn(), + raw: jest.fn(), + migrate: { currentVersion: jest.fn().mockResolvedValue('none'), down: jest.fn() } + } + await StorageKnex.prototype.dropAllData.call(storageWithKnex(database)) + expect(database.transaction).not.toHaveBeenCalled() + expect(database.raw).not.toHaveBeenCalled() + expect(database.migrate.down).not.toHaveBeenCalled() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index 07f48a6c6..3d47612f4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -1,3 +1,5 @@ +import { migrateGeneration, refuseGenerationDowngrade } from './snapshotSqliteIndexMigration' +import { migration as SNAPSHOT_SQLITE_INDEX_MIGRATION } from './snapshotSqliteIndexState' import { addSnapshotGlobalIndexes, removeSnapshotGlobalIndexes, @@ -53,6 +55,7 @@ import { } from '../methods/managedChangePolicy' export { SNAPSHOT_GLOBAL_INDEX_MIGRATION } from './snapshotGlobalIndexMigration' +export { migration as SNAPSHOT_SQLITE_INDEX_MIGRATION } from './snapshotSqliteIndexState' export { SNAPSHOT_CERTIFICATE_INDEX_MIGRATION } from './snapshotCertificateIndexMigration' export { SNAPSHOT_RELATION_INDEX_MIGRATION } from './snapshotRelationIndexMigration' export { SNAPSHOT_PROFILE_INDEX_MIGRATION } from './snapshotProfileIndexMigration' @@ -146,6 +149,12 @@ export class KnexMigrations implements MigrationSource { // DDL may commit independently on MySQL. Bootstrap pages retain their own // durable positions on both backends and resume before journal publication. + migrations[SNAPSHOT_SQLITE_INDEX_MIGRATION] = { + config: { transaction: false }, + up: migrateGeneration, + down: refuseGenerationDowngrade + } + migrations[SNAPSHOT_GLOBAL_INDEX_MIGRATION] = { config: { transaction: false }, up: addSnapshotGlobalIndexes, diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts index 115cf486e..20c63a522 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts @@ -55,7 +55,7 @@ export async function sqliteTable(k: Knex, table: Table, secondary: boolean): Pr } for (const index of required) { const found = indexes.find(value => value.name === index.name) - if (found === undefined || found.partial !== 0) return false + if (found?.partial !== 0) return false const parts = await sqliteParts(k, found.name) if ( parts.length !== index.columns.length || @@ -103,8 +103,7 @@ export async function validateSqliteSource(k: Knex): Promise { for (const field of source.fields) { const column = actual.find(value => value.name === field.name) if ( - column === undefined || - column.type.toLowerCase() !== (field.text ? 'varchar(64)' : 'integer') || + column?.type.toLowerCase() !== (field.text ? 'varchar(64)' : 'integer') || column.hidden !== 0 || (field.name !== source.key && column.notnull !== (field.nullable ? 0 : 1)) ) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentity.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentity.ts new file mode 100644 index 000000000..f5a81600d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentity.ts @@ -0,0 +1,130 @@ +import { runInSeries } from '../../utility/runInSeries' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { Knex } from 'knex' +// Metadata-bound conflict witnesses are maintained by the additive SQLite generation. +export interface SourceIdentity { + table: string + key: string + owner?: string +} +interface Part { + name: string + collation: string +} +interface Column { + name: string + type: string + pk: number + hidden: number +} +export interface IdentityDefinition { + source: SourceIdentity + table: string + columns: Column[] + unique: Part[][] +} +const quote = (name: string): string => '"' + name.replaceAll('"', '""') + '"' +export async function readIdentity(k: Knex, source: SourceIdentity): Promise { + const columns: Column[] = await k.raw('PRAGMA table_xinfo(??)', [source.table]) + const primary = columns.filter(column => column.pk !== 0) + if (primary.length !== 1 || primary[0].name !== source.key || primary[0].type.toLowerCase() !== 'integer') + throw new WERR_INVALID_OPERATION('Unsupported numeric identity') + const indexes: Array<{ + name: string + unique: number + partial: number + }> = await k.raw('PRAGMA index_list(??)', [source.table]) + const unique: Part[][] = [] + await runInSeries( + indexes.filter(index => index.unique !== 0), + async index => { + if (index.partial !== 0) throw new WERR_INVALID_OPERATION('Unsupported partial unique identity') + const metadata: Array<{ + name: string | null + coll: string + key: number + }> = await k.raw('PRAGMA index_xinfo(??)', [index.name]) + const parts = metadata.filter(part => part.key !== 0) + if ( + parts.length === 0 || + parts.some(part => part.name === null || !['BINARY', 'NOCASE', 'RTRIM'].includes(part.coll)) + ) + throw new WERR_INVALID_OPERATION('Unsupported unique identity comparison') + unique.push(parts.map(part => ({ name: part.name!, collation: part.coll }))) + } + ) + const names = new Set([ + source.key, + ...(source.owner ? [source.owner] : []), + ...unique.flatMap(parts => parts.map(part => part.name)) + ]) + const selected = [...names].map(name => columns.find(column => column.name === name)) + if ( + selected.some( + column => column?.hidden !== 0 || !/^(integer|bigint|boolean|varchar\([1-9]\d*\))$/i.test(column.type) + ) + ) + throw new WERR_INVALID_OPERATION('Unsupported identity column') + return { + source, + table: 'snapshot_identity_' + source.table, + columns: selected as Column[], + unique + } +} +export function identityDDL(identity: IdentityDefinition): string[] { + const primary = [identity.source.key, ...(identity.source.owner ? [identity.source.owner] : [])] + const fields = identity.columns.map( + column => `${quote(column.name)} ${column.type}${primary.includes(column.name) ? ' NOT NULL' : ''}` + ) + return [ + `CREATE TABLE ${quote(identity.table)} (${fields.join(',')},PRIMARY KEY (${primary.map(quote).join(',')}))`, + ...identity.unique.map( + (parts, index) => + `CREATE INDEX ${quote(identity.table + '_' + index)} ON ${quote(identity.table)} (${parts.map(part => quote(part.name) + ' COLLATE ' + part.collation).join(',')})` + ) + ] +} +/** SQL unique equality deliberately excludes NULL, as the source constraint does. */ +function conflicts(identity: IdentityDefinition, updated: boolean): string { + const key = quote(identity.source.key) + return [ + `${key}=NEW.${key}`, + ...(updated ? [`${key}=OLD.${key}`] : []), + ...identity.unique.map( + parts => + '(' + + parts.map(part => `${quote(part.name)} COLLATE ${part.collation}=NEW.${quote(part.name)}`).join(' AND ') + + ')' + ) + ].join(' OR ') +} +function merge(identity: IdentityDefinition, selection: string): string { + const columns = identity.columns.map(column => quote(column.name)) + const changed = columns.map(column => `${column} IS NOT excluded.${column}`).join(' OR ') + // A nested source trigger can expose a new owner before the outer trigger + // consumes the old one. Preserve both until membership cleanup runs. + const primary = [identity.source.key, ...(identity.source.owner ? [identity.source.owner] : [])] + const assignments = columns.map(column => `${column}=excluded.${column}`).join(',') + return `INSERT INTO ${quote(identity.table)} (${columns.join(',')}) ${selection} ON CONFLICT(${primary.map(quote).join(',')}) DO UPDATE SET ${assignments} WHERE ${changed};` +} +/** Observe existing rows only. An ignored source write cannot remove ownership. */ +export function observeIdentity(identity: IdentityDefinition, updated: boolean): string { + const columns = identity.columns.map(column => quote(column.name)).join(',') + return merge(identity, `SELECT ${columns} FROM ${quote(identity.source.table)} WHERE ${conflicts(identity, updated)}`) +} +/** Evaluated after the successful source operation, before the witness changes. */ +export function displacedIdentity(identity: IdentityDefinition, updated: boolean): string { + return `SELECT ${quote(identity.source.key)}${identity.source.owner ? ',' + quote(identity.source.owner) : ''} FROM ${quote(identity.table)} WHERE ${conflicts(identity, updated)}` +} +/** Run only after all affected membership families consume displacedIdentity. */ +export function finishIdentity(identity: IdentityDefinition, event: 'INSERT' | 'UPDATE' | 'DELETE'): string { + const key = quote(identity.source.key) + const row = event === 'DELETE' ? 'OLD' : 'NEW' + const remove = event === 'DELETE' ? `${key}=OLD.${key}` : conflicts(identity, event === 'UPDATE') + const columns = identity.columns.map(column => quote(column.name)).join(',') + return ( + `DELETE FROM ${quote(identity.table)} WHERE ${remove}; ` + + merge(identity, `SELECT ${columns} FROM ${quote(identity.source.table)} WHERE ${key}=${row}.${key}`) + ) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexBootstrap.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexBootstrap.ts new file mode 100644 index 000000000..b55bb7d71 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexBootstrap.ts @@ -0,0 +1,243 @@ +import { runInSeries } from '../../utility/runInSeries' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { Knex } from 'knex' +import { profiles, numeric, relations } from './snapshotSqliteMembership' +import { names, progress, metadata, validateInstalled, type Plan } from './snapshotSqliteIndexGeneration' +export interface Position { + stream: number + afterId: number + afterSecond: number + afterText: string + complete: number +} +const id = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0 +const nonnegative = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 +export function valid(state: Position): boolean { + if (state.stream !== 10 && state.afterText !== '') return false + if (state.stream !== 8 && state.stream !== 9 && state.afterSecond !== 0) return false + if ((state.stream === 8 || state.stream === 9) && (state.afterId === 0) !== (state.afterSecond === 0)) return false + if (state.stream === 10 && state.afterId === 0 && state.afterText !== '') return false + return ( + Number.isSafeInteger(state.stream) && + state.stream >= 0 && + state.stream < 12 && + nonnegative(state.afterId) && + nonnegative(state.afterSecond) && + typeof state.afterText === 'string' && + Array.from(state.afterText).length <= 100 && + Buffer.byteLength(state.afterText) <= 400 && + [0, 1].includes(state.complete) + ) +} +async function addProfile(trx: Knex, stream: number, state: Position): Promise> { + const { table, key } = numeric[stream] + const query = trx(table).select({ rowId: key }, 'userId').orderBy(key).limit(256) + if (state.afterId !== 0) void query.where(key, '>', state.afterId) + const rows: Array<{ + rowId: number + userId: number + }> = await query + if (rows.some(row => !id(row.rowId) || !id(row.userId))) + throw new WERR_INVALID_OPERATION('Invalid profile source identity') + if (rows.length) + await trx(names.profile) + .insert( + rows.map(row => ({ + snapshotTableId: stream, + snapshotUserId: row.userId, + snapshotRowId: row.rowId + })) + ) + .onConflict(['snapshotTableId', 'snapshotUserId', 'snapshotRowId']) + .ignore() + return { afterId: rows.at(-1)?.rowId ?? state.afterId, complete: rows.length < 256 ? 1 : 0 } +} +async function addRelations(trx: Knex, stream: number, state: Position): Promise> { + const relation = relations[stream - 8] + const query = trx(relation.table) + .select({ leftId: relation.leftKey, rightId: relation.rightKey }) + .orderBy([relation.leftKey, relation.rightKey]) + .limit(256) + if (state.afterId !== 0) + void query.whereRaw('(??,??)>(?,?)', [relation.leftKey, relation.rightKey, state.afterId, state.afterSecond]) + const rows: Array<{ + leftId: number + rightId: number + }> = await query + if (rows.some(row => !id(row.leftId) || !id(row.rightId))) + throw new WERR_INVALID_OPERATION('Invalid relation source identity') + await runInSeries(rows, async row => { + await runInSeries(['left', 'right'] as const, async side => { + const key = side === 'left' ? relation.leftKey : relation.rightKey + const bit = side === 'left' ? 1 : 2 + const owner = await trx(relation[side]) + .select('userId') + .where(key, side === 'left' ? row.leftId : row.rightId) + .first() + if (owner === undefined) return + if (!id(owner.userId)) throw new WERR_INVALID_OPERATION('Invalid relation source owner') + await trx(names.relation) + .insert({ + snapshotTableId: stream - 8, + snapshotUserId: owner.userId, + snapshotLeftId: row.leftId, + snapshotRightId: row.rightId, + snapshotMembership: bit + }) + .onConflict(['snapshotTableId', 'snapshotUserId', 'snapshotLeftId', 'snapshotRightId']) + .merge({ snapshotMembership: trx.raw('snapshotMembership | ?', [bit]) }) + }) + }) + return { + afterId: rows.at(-1)?.leftId ?? state.afterId, + afterSecond: rows.at(-1)?.rightId ?? state.afterSecond, + complete: rows.length < 256 ? 1 : 0 + } +} +async function addFields(trx: Knex, state: Position): Promise> { + const query = trx('certificate_fields') + .select( + 'certificateId', + 'userId', + trx.raw('CASE WHEN length(CAST(fieldName AS BLOB))<=400 THEN fieldName END AS fieldName'), + trx.raw('length(CAST(fieldName AS BLOB)) AS bytes') + ) + .orderBy(['fieldName', 'certificateId']) + .limit(256) + if (state.afterId !== 0) void query.whereRaw('(fieldName,certificateId)>(?,?)', [state.afterText, state.afterId]) + const rows: Array<{ + fieldName: string | null + certificateId: number + userId: number + bytes: number + }> = await query + if ( + rows.some( + row => + typeof row.fieldName !== 'string' || + Array.from(row.fieldName).length > 100 || + Buffer.byteLength(row.fieldName) !== row.bytes || + !id(row.certificateId) || + !id(row.userId) + ) + ) + throw new WERR_INVALID_OPERATION('Invalid certificate source identity') + await runInSeries(rows, async row => { + const parent = await trx('certificates').where('certificateId', row.certificateId).first('userId') + const owners = [ + { userId: row.userId, bit: 1 }, + ...(parent === undefined ? [] : [{ userId: parent.userId, bit: 2 }]) + ] + await runInSeries(owners, async owner => { + if (!id(owner.userId)) throw new WERR_INVALID_OPERATION('Invalid certificate source owner') + await trx(names.certificate) + .insert({ + snapshotUserId: owner.userId, + snapshotFieldName: row.fieldName, + snapshotCertificateId: row.certificateId, + snapshotMembership: owner.bit + }) + .onConflict(['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId']) + .merge({ snapshotMembership: trx.raw('snapshotMembership | ?', [owner.bit]) }) + }) + }) + return { + afterText: rows.at(-1)?.fieldName ?? state.afterText, + afterId: rows.at(-1)?.certificateId ?? state.afterId, + complete: rows.length < 256 ? 1 : 0 + } +} +async function addGlobal(trx: Knex, state: Position): Promise> { + const query = trx('transactions') + .select( + 'transactionId', + 'userId', + 'provenTxId', + trx.raw('CASE WHEN length(CAST(txid AS BLOB))<=256 THEN txid END AS txid'), + trx.raw('length(CAST(txid AS BLOB)) AS bytes') + ) + .orderBy('transactionId') + .limit(256) + if (state.afterId !== 0) void query.where('transactionId', '>', state.afterId) + const rows: Array<{ + transactionId: number + userId: number + provenTxId: number | null + txid: string | null + bytes: number | null + }> = await query + await runInSeries(rows, async row => { + if ( + !id(row.transactionId) || + !id(row.userId) || + (row.provenTxId !== null && !id(row.provenTxId)) || + (row.bytes !== null && + (typeof row.txid !== 'string' || Array.from(row.txid).length > 64 || Buffer.byteLength(row.txid) !== row.bytes)) + ) + throw new WERR_INVALID_OPERATION('Invalid global source identity') + const request = + row.txid === null + ? undefined + : await trx('proven_tx_reqs').where('txid', row.txid).first('provenTxReqId', 'provenTxId') + if ( + request !== undefined && + (!id(request.provenTxReqId) || (request.provenTxId !== null && !id(request.provenTxId))) + ) + throw new WERR_INVALID_OPERATION('Invalid global request identity') + const edges: Array<{ requestId: number; tableId: number; rowId: number }> = [] + if (row.provenTxId !== null) edges.push({ requestId: 0, tableId: 1, rowId: row.provenTxId }) + if (request !== undefined) { + edges.push({ requestId: request.provenTxReqId, tableId: 0, rowId: request.provenTxReqId }) + if (request.provenTxId !== null) + edges.push({ requestId: request.provenTxReqId, tableId: 1, rowId: request.provenTxId }) + } + await runInSeries(edges, async edge => { + if (edge.tableId === 1) { + const present = (await trx('proven_txs').where('provenTxId', edge.rowId).first('provenTxId')) !== undefined + await trx(names.guards).insert({ proofId: edge.rowId, present }).onConflict('proofId').merge({ present }) + } + await trx(names.edges) + .insert({ ...edge, transactionId: row.transactionId, userId: row.userId }) + .onConflict(['transactionId', 'requestId', 'tableId', 'rowId']) + .ignore() + }) + }) + return { + afterId: rows.at(-1)?.transactionId ?? state.afterId, + complete: rows.length < 256 ? 1 : 0 + } +} +async function copyStream(trx: Knex, state: Position): Promise> { + if (state.stream < profiles.length) return await addProfile(trx, state.stream, state) + if (state.stream < 10) return await addRelations(trx, state.stream, state) + if (state.stream === 10) return await addFields(trx, state) + return await addGlobal(trx, state) +} + +/** Exactly one bounded source page and its cursor commit together. */ +export async function copyGenerationPage( + k: Knex, + plan: Plan +): Promise<{ + complete: boolean + stream: number + copiedThrough: Position | undefined +}> { + return await k.transaction(async trx => { + await trx(metadata) + .where('id', 0) + .update({ complete: trx.ref('complete') }) + const states: Position[] = await trx(progress).select('*').orderBy('stream').limit(13) + if (states.length !== 12 || states.some((state, index) => state.stream !== index || !valid(state))) + throw new WERR_INVALID_OPERATION('Invalid generation progress') + const state = states.find(state => state.complete === 0) + if (state === undefined) { + await validateInstalled(trx, plan) + await trx(metadata).where('id', 0).update({ complete: true }) + return { complete: true, stream: 12, copiedThrough: undefined } + } + const next = await copyStream(trx, state) + await trx(progress).where('stream', state.stream).update(next) + return { complete: false, stream: state.stream, copiedThrough: { ...state, ...next } } + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts new file mode 100644 index 000000000..31f550766 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts @@ -0,0 +1,287 @@ +import { runInSeries } from '../../utility/runInSeries' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { validateLegacy, legacySchema, retiredTables } from './snapshotSqliteLegacyOwnership' +import type { Knex } from 'knex' +import { readIdentity, identityDDL, type IdentityDefinition } from './snapshotSqliteIdentity' +import { numeric, relations, membershipTriggers, type MembershipNames } from './snapshotSqliteMembership' +import { validateSqliteSource } from './snapshotGlobalIndexSqlite' +import { snapshotGlobalIndexTriggers } from './snapshotGlobalIndexTriggers' +export const names: MembershipNames = { + profile: 'snapshot_profile_keys_v2', + relation: 'snapshot_relation_keys_v2', + certificate: 'snapshot_certificate_field_keys_v2', + edges: 'snapshot_global_edges_v2', + keys: 'snapshot_global_keys_v2', + guards: 'snapshot_global_guards_v2' +} +export const progress = 'snapshot_index_rebuild_v2' +export const metadata = 'snapshot_index_generation_v2' +export const oldProgress = [ + 'snapshot_profile_index_progress', + 'snapshot_relation_index_progress', + 'snapshot_certificate_index_progress', + 'snapshot_global_index_progress' +] +const q = (name: string) => '"' + name.replaceAll('"', '""') + '"' +interface Part { + name: string | null + coll: string + key: number + desc: number +} +export interface Plan { + identities: IdentityDefinition[] + fieldCollation: string + source: string + ddl: string[] + triggers: string[] +} +async function compositeOrder(k: Knex, table: string, columns: string[]): Promise { + const indexes: Array<{ + name: string + unique: number + partial: number + }> = await k.raw('PRAGMA index_list(??)', [table]) + const unique = indexes.filter(index => index.unique !== 0) + if (unique.length !== 1 || unique[0].partial !== 0) + throw new WERR_INVALID_OPERATION('Unsupported composite identity constraints') + const parts: Part[] = (await k.raw('PRAGMA index_xinfo(??)', [unique[0].name])).filter((part: Part) => part.key !== 0) + if ( + parts.length !== columns.length || + parts.some( + (part, i) => part.name !== columns[i] || part.desc !== 0 || !['BINARY', 'NOCASE', 'RTRIM'].includes(part.coll) + ) || + parts.slice(table === 'certificate_fields' ? 1 : 0).some(part => part.coll !== 'BINARY') + ) + throw new WERR_INVALID_OPERATION('Unsupported composite identity comparison') + const plan: Array<{ + detail: string + }> = await k.raw( + `EXPLAIN QUERY PLAN SELECT ${columns.map(q).join(',')} FROM ${q(table)} INDEXED BY ${q(unique[0].name)} ORDER BY ${columns.map(q).join(',')} LIMIT 1` + ) + if (plan.some(step => step.detail.includes('TEMP B-TREE'))) + throw new WERR_INVALID_OPERATION('Composite source order mismatch') + return parts[0].coll +} +function tables(fieldCollation: string): string[] { + const definitions: Array<[string, string, string[]]> = [ + [ + names.profile, + 'snapshotTableId INTEGER NOT NULL,snapshotUserId INTEGER NOT NULL,snapshotRowId INTEGER NOT NULL', + ['snapshotTableId', 'snapshotUserId', 'snapshotRowId'] + ], + [ + names.relation, + 'snapshotTableId INTEGER NOT NULL,snapshotUserId INTEGER NOT NULL,snapshotLeftId INTEGER NOT NULL,snapshotRightId INTEGER NOT NULL,snapshotMembership INTEGER NOT NULL', + ['snapshotTableId', 'snapshotUserId', 'snapshotLeftId', 'snapshotRightId'] + ], + [ + names.certificate, + `snapshotUserId INTEGER NOT NULL,snapshotFieldName VARCHAR(100) COLLATE ${fieldCollation} NOT NULL,snapshotCertificateId INTEGER NOT NULL,snapshotMembership INTEGER NOT NULL`, + ['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId'] + ], + [ + names.edges, + 'transactionId INTEGER NOT NULL,requestId INTEGER NOT NULL,tableId INTEGER NOT NULL,rowId INTEGER NOT NULL,userId INTEGER NOT NULL', + ['transactionId', 'requestId', 'tableId', 'rowId'] + ], + [ + names.keys, + 'tableId INTEGER NOT NULL,userId INTEGER NOT NULL,rowId INTEGER NOT NULL,refs BIGINT NOT NULL,present BOOLEAN NOT NULL', + ['tableId', 'userId', 'rowId'] + ], + [names.guards, 'proofId INTEGER NOT NULL,present BOOLEAN NOT NULL', ['proofId']], + [ + progress, + `stream INTEGER NOT NULL,afterId INTEGER NOT NULL,afterSecond INTEGER NOT NULL,afterText VARCHAR(100) COLLATE ${fieldCollation} NOT NULL,complete BOOLEAN NOT NULL`, + ['stream'] + ], + [ + metadata, + 'id INTEGER NOT NULL,source TEXT NOT NULL,complete BOOLEAN NOT NULL,legacy TEXT NOT NULL,retireTable INTEGER NOT NULL', + ['id'] + ] + ] + const indexes: Array<[string, string, string[]]> = [ + [ + 'snapshot_relation_right_v2', + names.relation, + ['snapshotTableId', 'snapshotUserId', 'snapshotRightId', 'snapshotLeftId'] + ], + [ + 'snapshot_relation_map_v2', + names.relation, + ['snapshotTableId', 'snapshotLeftId', 'snapshotRightId', 'snapshotUserId'] + ], + [ + 'snapshot_certificate_parent_v2', + names.certificate, + ['snapshotCertificateId', 'snapshotUserId', 'snapshotFieldName'] + ], + [ + 'snapshot_certificate_lookup_v2', + names.certificate, + ['snapshotFieldName', 'snapshotCertificateId', 'snapshotUserId'] + ], + ['snapshot_global_page_v2', names.keys, ['tableId', 'userId', 'present', 'rowId']], + ['snapshot_global_target_v2', names.keys, ['tableId', 'rowId', 'userId']], + ['snapshot_global_request_v2', names.edges, ['requestId', 'transactionId']] + ] + return [ + ...definitions.map( + ([name, columns, primary]) => `CREATE TABLE ${q(name)} (${columns},PRIMARY KEY (${primary.map(q).join(',')}))` + ), + ...indexes.map(([name, table, columns]) => `CREATE INDEX ${q(name)} ON ${q(table)} (${columns.map(q).join(',')})`) + ] +} +export async function readPlan(k: Knex): Promise { + if (!String(k.client.config.client).includes('sqlite')) + throw new WERR_INVALID_OPERATION('SQLite rebuild requires SQLite') + await validateSqliteSource(k) + const identities: IdentityDefinition[] = [] + await runInSeries(numeric, async source => { + identities.push(await readIdentity(k, source)) + }) + await runInSeries(relations, async relation => { + await compositeOrder(k, relation.table, [relation.leftKey, relation.rightKey]) + }) + const fieldCollation = await compositeOrder(k, 'certificate_fields', ['fieldName', 'certificateId']) + const sources = [ + ...numeric.map(source => source.table), + ...relations.map(relation => relation.table), + 'certificate_fields' + ] + const schema = await k('sqlite_master') + .whereIn('tbl_name', sources) + .whereIn('type', ['table', 'index']) + .select('type', 'name', 'tbl_name', 'sql') + .orderBy(['type', 'name']) + const source = JSON.stringify(schema) + const ddl = [...tables(fieldCollation), ...identities.flatMap(identityDDL)] + const observer = snapshotGlobalIndexTriggers(false) + .filter(trigger => trigger.table === 'snapshot_global_edges') + .map(trigger => + trigger.sql + .replaceAll('snapshot_global_edges', names.edges) + .replaceAll('snapshot_global_keys', names.keys) + .replaceAll('snapshot_global_guards', names.guards) + .replaceAll('snapshot_global_edge_', 'snapshot_global_v2_edge_') + ) + return { + identities, + fieldCollation, + source, + ddl, + triggers: [...observer, ...membershipTriggers(identities, names)] + } +} +async function validateInstallLock(k: Knex): Promise { + const lock = await k('sqlite_master').where({ type: 'table', name: 'snapshot_index_install_lock_v2' }).first('sql') + if (lock?.sql !== 'CREATE TABLE snapshot_index_install_lock_v2(id INTEGER PRIMARY KEY)') + throw new WERR_INVALID_OPERATION('Invalid rebuild lock definition') +} + +/** Exact generated DDL ownership; never adopt a similarly named foreign object. */ +export async function validateInstalled(k: Knex, plan: Plan): Promise { + await validateInstallLock(k) + if ((await readPlan(k)).source !== plan.source) throw new WERR_INVALID_OPERATION('Rebuild source schema changed') + const expected = [...plan.ddl, ...plan.triggers] + await runInSeries(expected, async sql => { + const match = /^CREATE (TABLE|INDEX|TRIGGER) ("[^"]+"|\w+)/.exec(sql) + if (!match) throw new WERR_INVALID_OPERATION('Invalid generated schema definition') + const name = match[2].replaceAll('"', '') + const rows: Array<{ + sql: string + }> = await k('sqlite_master').where({ type: match[1].toLowerCase(), name }).select('sql') + if (rows.length !== 1 || rows[0].sql !== sql) + throw new WERR_INVALID_OPERATION('Rebuild schema definition mismatch: ' + name) + }) + const rows: Array<{ + id: number + source: string + complete: number + legacy: string + retireTable: number + }> = await k(metadata).select('*').limit(2) + if ( + rows.length !== 1 || + rows[0].id !== 0 || + rows[0].source !== plan.source || + ![0, 1].includes(rows[0].complete) || + !Number.isSafeInteger(rows[0].retireTable) || + rows[0].retireTable < 0 || + rows[0].retireTable > retiredTables.length || + typeof rows[0].legacy !== 'string' + ) + throw new WERR_INVALID_OPERATION('Rebuild source binding mismatch') +} +/** Atomically install owned observers and invalidate the previous generation. */ +export async function installGeneration(k: Knex, config?: Knex.MigratorConfig): Promise { + return await k.transaction(async trx => { + // A harmless DDL write reserves SQLite's writer lock before schema observation. + // The connection remains in this transaction until all observer swaps commit. + await trx.raw('CREATE TABLE IF NOT EXISTS snapshot_index_install_lock_v2(id INTEGER PRIMARY KEY)') + await validateInstallLock(trx) + await trx.raw('UPDATE snapshot_index_install_lock_v2 SET id=id') + const plan = await readPlan(trx) + if (await trx.schema.hasTable(metadata)) { + await validateInstalled(trx, plan) + return plan + } + const legacyNames = await validateLegacy(trx, config) + const namesToOwn = plan.ddl + .map(sql => /^CREATE (?:TABLE|INDEX) "([^"]+)"/.exec(sql)?.[1]) + .filter((name): name is string => name !== undefined) + if ((await trx('sqlite_master').whereIn('name', namesToOwn)).length !== 0) + throw new WERR_INVALID_OPERATION('Orphan rebuild schema refuses adoption') + await runInSeries(plan.ddl, async sql => { + await trx.raw(sql) + }) + const old: Array<{ + name: string + }> = await trx('sqlite_master') + .where('type', 'trigger') + .whereIn('tbl_name', [ + ...numeric.map(source => source.table), + ...relations.map(relation => relation.table), + 'certificate_fields' + ]) + .select('name') + await runInSeries(old, async trigger => { + if (/^snapshot_(profile|relation|certificate|global)_/.test(trigger.name)) { + if (!legacyNames.has(trigger.name)) throw new WERR_INVALID_OPERATION('Unknown legacy source trigger') + await trx.raw('DROP TRIGGER ??', [trigger.name]) + } + }) + await runInSeries( + snapshotGlobalIndexTriggers(false).filter(trigger => trigger.table === 'snapshot_global_edges'), + async trigger => { + await trx.raw('DROP TRIGGER ??', [trigger.name]) + } + ) + const legacy = JSON.stringify(await legacySchema(trx)) + await runInSeries(plan.triggers, async sql => { + await trx.raw(sql) + }) + await runInSeries(oldProgress, async table => { + await trx(table).update({ complete: false }) + }) + await trx(metadata).insert({ + id: 0, + source: plan.source, + complete: false, + legacy, + retireTable: 0 + }) + await trx(progress).insert( + Array.from({ length: 12 }, (_, stream) => ({ + stream, + afterId: 0, + afterSecond: 0, + afterText: '', + complete: false + })) + ) + return plan + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexMigration.ts new file mode 100644 index 000000000..4fed0edea --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexMigration.ts @@ -0,0 +1,56 @@ +import { runInSeries } from '../../utility/runInSeries' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { Knex } from 'knex' +import { installGeneration, metadata, readPlan, validateInstalled } from './snapshotSqliteIndexGeneration' +import { copyGenerationPage } from './snapshotSqliteIndexBootstrap' +import { retireGenerationPage } from './snapshotSqliteIndexRetirement' +/** Pull one page at a time, yielding foreground execution between commits. */ +async function completePages(read: () => Promise<{ complete: boolean }>): Promise { + let complete = false + function* pending() { + while (!complete) yield undefined + } + await runInSeries(pending(), async () => { + complete = (await read()).complete + if (!complete) await new Promise(resolve => setTimeout(resolve, 0)) + }) +} + +/** Additive SQLite repair; MySQL retains its existing native maintenance. */ +export async function migrateGeneration(k: Knex): Promise { + if (String(k.client.config.client).includes('mysql')) return + if (k.isTransaction) throw new WERR_INVALID_OPERATION('SQLite generation requires independent bounded transactions') + const plan = await installGeneration(k, k.client.config.migrations) + await completePages(() => copyGenerationPage(k, plan)) + await completePages(() => retireGenerationPage(k, plan)) +} +/** Source rows are unchanged; an older snapshot implementation cannot use retired indexes. */ +export function refuseGenerationDowngrade(k: Knex): Promise { + if (String(k.client.config.client).includes('mysql')) return Promise.resolve() + return Promise.reject( + new WERR_INVALID_OPERATION( + 'SQLite snapshot generation downgrade is unsupported; preserve source rows and use a forward migration' + ) + ) +} +/** Only StorageKnex.dropAllData may remove this generation without rebuilding legacy indexes. */ +export async function dropGenerationForDataDeletion(k: Knex): Promise { + if (!String(k.client.config.client).includes('sqlite')) return + await k.transaction(async trx => { + if (!(await trx.schema.hasTable(metadata))) return + await trx(metadata) + .where('id', 0) + .update({ retireTable: trx.ref('retireTable') }) + const plan = await readPlan(trx) + await validateInstalled(trx, plan) + await runInSeries([...plan.triggers].reverse(), async sql => { + const name = /^CREATE TRIGGER ("[^"]+"|\w+)/.exec(sql)![1].replaceAll('"', '') + await trx.raw('DROP TRIGGER ??', [name]) + }) + await runInSeries([...plan.ddl].reverse(), async sql => { + const match = /^CREATE TABLE "([^"]+)"/.exec(sql) + if (match) await trx.schema.dropTable(match[1]) + }) + await trx.schema.dropTable('snapshot_index_install_lock_v2') + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexRetirement.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexRetirement.ts new file mode 100644 index 000000000..28cf2ae11 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexRetirement.ts @@ -0,0 +1,59 @@ +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { Knex } from 'knex' +import { metadata, validateInstalled, type Plan } from './snapshotSqliteIndexGeneration' +import { legacySchema, retiredTables, type SchemaObject } from './snapshotSqliteLegacyOwnership' + +/** Remove at most 256 obsolete rows; drop only an already-empty owned table. */ +export async function retireGenerationPage( + k: Knex, + plan: Plan +): Promise<{ complete: boolean; removed: number; table: string | undefined }> { + return await k.transaction(async trx => { + await trx(metadata) + .where('id', 0) + .update({ retireTable: trx.ref('retireTable') }) + await validateInstalled(trx, plan) + const state: { complete: number; legacy: string; retireTable: number } = await trx(metadata).where('id', 0).first() + if (state.complete !== 1) throw new WERR_INVALID_OPERATION('Cannot retire before generation copy completes') + let expected: SchemaObject[] + try { + expected = JSON.parse(state.legacy) + } catch { + throw new WERR_INVALID_OPERATION('Invalid legacy ownership receipt') + } + if ( + !Array.isArray(expected) || + expected.some( + row => + typeof row !== 'object' || + row === null || + !retiredTables.includes(row.tbl_name) || + !['table', 'index'].includes(row.type) || + typeof row.name !== 'string' || + (row.sql !== null && typeof row.sql !== 'string') + ) + ) + throw new WERR_INVALID_OPERATION('Invalid legacy ownership receipt') + const remaining = expected.filter(row => retiredTables.indexOf(row.tbl_name) >= state.retireTable) + if (JSON.stringify(await legacySchema(trx)) !== JSON.stringify(remaining)) + throw new WERR_INVALID_OPERATION('Legacy retirement schema changed') + const table = retiredTables[state.retireTable] + if (table === undefined) return { complete: true, removed: 0, table } + const removed = await trx(table) + .whereIn('_rowid_', trx(table).select('_rowid_').orderBy('_rowid_').limit(256)) + .delete() + if (!Number.isSafeInteger(removed) || removed < 0 || removed > 256) + throw new WERR_INVALID_OPERATION('Invalid retirement row count') + if (removed === 0) { + await trx.schema.dropTable(table) + await trx(metadata) + .where('id', 0) + .update({ retireTable: state.retireTable + 1 }) + } + return { + complete: removed === 0 && state.retireTable + 1 === retiredTables.length, + removed, + table + } + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexState.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexState.ts new file mode 100644 index 000000000..de22d2b04 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexState.ts @@ -0,0 +1,35 @@ +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { Knex } from 'knex' +import { readPlan, validateInstalled, metadata, progress } from './snapshotSqliteIndexGeneration' +import { valid, type Position } from './snapshotSqliteIndexBootstrap' +export const migration = '2026-10-02-001 repair snapshot SQLite conflict maintenance' +export type SnapshotIndexState = boolean | 'v2' +/** The caller holds the same pinned view that will read all selected pages. */ +export async function readGenerationIndexState( + k: Knex, + config?: Knex.MigratorConfig +): Promise { + if (!String(k.client.config.client).includes('sqlite') || !(await k.schema.hasTable(metadata))) return undefined + const plan = await readPlan(k) + await validateInstalled(k, plan) + const rows: Position[] = await k(progress).select('*').orderBy('stream').limit(13) + if (rows.length !== 12 || rows.some((row, index) => row.stream !== index || !valid(row))) + throw new WERR_INVALID_OPERATION('Invalid generation progress') + const state = await k(metadata).where('id', 0).first('complete') + const journal = config?.tableName ?? 'knex_migrations' + const journalSchema = k.schema + if (config?.schemaName !== undefined) void journalSchema.withSchema(config.schemaName) + let published = false + if (await journalSchema.hasTable(journal)) { + const query = k(journal).where('name', migration) + if (config?.schemaName !== undefined) void query.withSchema(config.schemaName) + published = (await query.first('name')) !== undefined + } + if (state.complete === 0) { + if (published) throw new WERR_INVALID_OPERATION('Published generation is incomplete') + return false + } + if (rows.some(row => row.complete !== 1)) + throw new WERR_INVALID_OPERATION('Completed generation has unfinished streams') + return published ? 'v2' : false +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts new file mode 100644 index 000000000..d82b0a2c1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts @@ -0,0 +1,76 @@ +import { runInSeries } from '../../utility/runInSeries' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { Knex } from 'knex' +import { snapshotGlobalIndexTriggers } from './snapshotGlobalIndexTriggers' +import { addSnapshotProfileIndexes, readSnapshotProfileIndexState } from './snapshotProfileIndexMigration' +import { addSnapshotRelationIndexes, readSnapshotRelationIndexState } from './snapshotRelationIndexMigration' +import { addSnapshotCertificateIndexes, readSnapshotCertificateIndexState } from './snapshotCertificateIndexMigration' +import { addSnapshotGlobalIndexes, readSnapshotGlobalIndexState } from './snapshotGlobalIndexMigration' +import { legacyNames } from './snapshotSqliteMembership' +export const retiredTables = [ + legacyNames.edges, + legacyNames.keys, + legacyNames.guards, + legacyNames.profile, + legacyNames.relation, + legacyNames.certificate +] +export interface SchemaObject { + type: string + name: string + tbl_name: string + sql: string | null +} +export async function legacySchema(k: Knex): Promise { + return await k('sqlite_master') + .whereIn('tbl_name', retiredTables) + .select('type', 'name', 'tbl_name', 'sql') + .orderBy(['type', 'name']) +} +function legacyTriggerNames(): Set { + const names = new Set(snapshotGlobalIndexTriggers(false).map(trigger => trigger.name)) + for (let id = 0; id < 8; id++) + for (const event of ['insert', 'update', 'delete']) names.add(`snapshot_profile_${id}_${event}`) + for (let id = 0; id < 2; id++) + for (const side of ['map', 'left', 'right']) + for (const event of ['insert', 'delete', 'before_update', 'after_update']) + names.add(`snapshot_relation_${id}_${side}_${event}`) + for (const side of ['field', 'parent']) + for (const event of ['insert', 'delete', 'before_update', 'after_update']) + names.add(`snapshot_certificate_${side}_${event}`) + return names +} + +/** Existing validators own the old definitions; complete states prevent bootstrap scans. */ +export async function validateLegacy(k: Knex, config?: Knex.MigratorConfig): Promise> { + await runInSeries( + [ + readSnapshotProfileIndexState, + readSnapshotRelationIndexState, + readSnapshotCertificateIndexState, + readSnapshotGlobalIndexState + ], + async read => { + if (!(await read(k, config))) throw new WERR_INVALID_OPERATION('Prior snapshot migrations must be complete') + } + ) + await runInSeries( + [addSnapshotProfileIndexes, addSnapshotRelationIndexes, addSnapshotCertificateIndexes, addSnapshotGlobalIndexes], + async install => { + await install(k) + } + ) + const names = legacyTriggerNames() + const foreign: Array<{ + name: string + sql: string + }> = await k('sqlite_master').whereIn('type', ['trigger', 'view']).select('name', 'sql') + for (const object of foreign) + if (!names.has(object.name) && retiredTables.some(table => new RegExp('\\b' + table + '\\b', 'i').test(object.sql))) + throw new WERR_INVALID_OPERATION('Unowned object references legacy auxiliary data') + const aux = await legacySchema(k) + for (const row of aux) + if (row.type === 'trigger' && !names.has(row.name)) + throw new WERR_INVALID_OPERATION('Unknown legacy auxiliary trigger') + return names +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteMembership.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteMembership.ts new file mode 100644 index 000000000..7df0d240b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteMembership.ts @@ -0,0 +1,246 @@ +import { + observeIdentity, + displacedIdentity, + finishIdentity, + type IdentityDefinition, + type SourceIdentity +} from './snapshotSqliteIdentity' + +// Pure maintenance SQL shared by generation installation and focused fixture tests. +export const profiles = [ + 'transactions', + 'outputs', + 'certificates', + 'tx_labels', + 'output_baskets', + 'output_tags', + 'commissions', + 'sync_states' +] +export const numeric: SourceIdentity[] = [ + { table: 'transactions', key: 'transactionId', owner: 'userId' }, + { table: 'outputs', key: 'outputId', owner: 'userId' }, + { table: 'certificates', key: 'certificateId', owner: 'userId' }, + { table: 'tx_labels', key: 'txLabelId', owner: 'userId' }, + { table: 'output_baskets', key: 'basketId', owner: 'userId' }, + { table: 'output_tags', key: 'outputTagId', owner: 'userId' }, + { table: 'commissions', key: 'commissionId', owner: 'userId' }, + { table: 'sync_states', key: 'syncStateId', owner: 'userId' }, + { table: 'proven_txs', key: 'provenTxId' }, + { table: 'proven_tx_reqs', key: 'provenTxReqId' } +] +export const relations = [ + { + table: 'tx_labels_map', + left: 'tx_labels', + leftKey: 'txLabelId', + right: 'transactions', + rightKey: 'transactionId' + }, + { + table: 'output_tags_map', + left: 'output_tags', + leftKey: 'outputTagId', + right: 'outputs', + rightKey: 'outputId' + } +] +type Event = 'INSERT' | 'UPDATE' | 'DELETE' +const q = (name: string): string => '"' + name.replaceAll('"', '""') + '"' +const relationColumns = 'snapshotTableId,snapshotUserId,snapshotLeftId,snapshotRightId' +const fieldColumns = 'snapshotUserId,snapshotFieldName,snapshotCertificateId' + +export interface MembershipNames { + profile: string + relation: string + certificate: string + edges: string + keys: string + guards: string +} +export const legacyNames: MembershipNames = { + profile: 'snapshot_profile_keys', + relation: 'snapshot_relation_keys', + certificate: 'snapshot_certificate_field_keys', + edges: 'snapshot_global_edges', + keys: 'snapshot_global_keys', + guards: 'snapshot_global_guards' +} +export function membershipBodies(names: MembershipNames = legacyNames) { + function relationInsert(select: string, bit: number): string { + return `INSERT INTO ${names.relation}(${relationColumns},snapshotMembership) ${select} ON CONFLICT(${relationColumns}) DO UPDATE SET snapshotMembership=snapshotMembership|${bit}; ` + } + function fieldInsert(select: string, bit: number): string { + return `INSERT INTO ${names.certificate}(${fieldColumns},snapshotMembership) ${select} ON CONFLICT(${fieldColumns}) DO UPDATE SET snapshotMembership=snapshotMembership|${bit}; ` + } + function edgeInsert(select: string): string { + return `INSERT INTO ${names.edges}(transactionId,requestId,tableId,rowId,userId) ${select} ON CONFLICT(transactionId,requestId,tableId,rowId) DO NOTHING; ` + } + function ensureProof(expression: string): string { + return `INSERT INTO ${names.guards}(proofId,present) SELECT ${expression},EXISTS(SELECT 1 FROM proven_txs WHERE provenTxId=${expression}) WHERE ${expression} IS NOT NULL ON CONFLICT(proofId) DO UPDATE SET present=excluded.present; ` + } + + function profileMembership(table: string, key: string, context: string, owners: string): string { + let sql = '' + const profileId = profiles.indexOf(table) + if (profileId !== -1) { + sql += `DELETE FROM ${names.profile} WHERE snapshotTableId=${profileId} AND (snapshotUserId,snapshotRowId) IN (${owners}); ` + sql += `INSERT INTO ${names.profile}(snapshotTableId,snapshotUserId,snapshotRowId) SELECT ${profileId},userId,${q(key)} FROM ${q(table)} WHERE ${q(key)}=${context}.${q(key)} ON CONFLICT(snapshotTableId,snapshotUserId,snapshotRowId) DO NOTHING; ` + } + return sql + } + function parentMembership(table: string, key: string, context: string, owners: string): string { + let sql = '' + for (const [id, relation] of relations.entries()) + for (const side of ['left', 'right'] as const) { + if (relation[side] !== table) continue + const bit = side === 'left' ? 1 : 2 + const index = side === 'left' ? 'snapshotLeftId' : 'snapshotRightId' + const where = `snapshotTableId=${id} AND (snapshotUserId,${index}) IN (${owners})` + sql += `UPDATE ${names.relation} SET snapshotMembership=snapshotMembership&${3 ^ bit} WHERE ${where}; DELETE FROM ${names.relation} WHERE ${where} AND snapshotMembership=0; ` + sql += relationInsert( + `SELECT ${id},p.userId,m.${relation.leftKey},m.${relation.rightKey},${bit} FROM ${q(table)} p JOIN ${relation.table} m ON m.${key}=p.${key} WHERE p.${key}=${context}.${key}`, + bit + ) + } + if (table === 'certificates') { + const where = `(snapshotUserId,snapshotCertificateId) IN (${owners})` + sql += `UPDATE ${names.certificate} SET snapshotMembership=snapshotMembership&1 WHERE ${where}; DELETE FROM ${names.certificate} WHERE ${where} AND snapshotMembership=0; ` + sql += fieldInsert( + `SELECT c.userId,f.fieldName,f.certificateId,2 FROM certificates c JOIN ${names.certificate} k ON k.snapshotCertificateId=c.certificateId AND (k.snapshotMembership&1)=1 JOIN certificate_fields f ON f.fieldName=k.snapshotFieldName AND f.certificateId=k.snapshotCertificateId WHERE c.certificateId=${context}.certificateId`, + 2 + ) + } + return sql + } + function globalMembership(table: string, context: string, ids: string): string { + let sql = '' + if (table === 'transactions') { + sql += `DELETE FROM ${names.edges} WHERE transactionId IN (${ids}); ` + sql += ensureProof(`(SELECT provenTxId FROM transactions WHERE transactionId=${context}.transactionId)`) + sql += ensureProof( + `(SELECT r.provenTxId FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid WHERE t.transactionId=${context}.transactionId)` + ) + sql += edgeInsert( + `SELECT t.transactionId,0,1,t.provenTxId,t.userId FROM transactions t WHERE t.transactionId=${context}.transactionId AND t.provenTxId IS NOT NULL` + ) + sql += edgeInsert( + `SELECT t.transactionId,r.provenTxReqId,0,r.provenTxReqId,t.userId FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid WHERE t.transactionId=${context}.transactionId` + ) + sql += edgeInsert( + `SELECT t.transactionId,r.provenTxReqId,1,r.provenTxId,t.userId FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid WHERE t.transactionId=${context}.transactionId AND r.provenTxId IS NOT NULL` + ) + } + if (table === 'proven_tx_reqs') { + sql += `DELETE FROM ${names.edges} WHERE requestId IN (${ids}); ` + const proof = `(SELECT provenTxId FROM proven_tx_reqs WHERE provenTxReqId=${context}.provenTxReqId)` + sql += ensureProof(proof) + sql += edgeInsert( + `SELECT t.transactionId,r.provenTxReqId,0,r.provenTxReqId,t.userId FROM proven_tx_reqs r JOIN transactions t ON t.txid=r.txid WHERE r.provenTxReqId=${context}.provenTxReqId` + ) + sql += edgeInsert( + `SELECT t.transactionId,r.provenTxReqId,1,r.provenTxId,t.userId FROM proven_tx_reqs r JOIN transactions t ON t.txid=r.txid WHERE r.provenTxReqId=${context}.provenTxReqId AND r.provenTxId IS NOT NULL` + ) + sql += `DELETE FROM ${names.guards} WHERE proofId=${proof} AND NOT EXISTS(SELECT 1 FROM ${names.keys} WHERE tableId=1 AND rowId=proofId); ` + } + if (table === 'proven_txs') { + sql += `UPDATE ${names.guards} SET present=0 WHERE proofId IN (${ids}); UPDATE ${names.keys} SET present=0 WHERE tableId=1 AND rowId IN (${ids}); DELETE FROM ${names.guards} WHERE proofId IN (${ids}) AND NOT EXISTS(SELECT 1 FROM ${names.keys} WHERE tableId=1 AND rowId=proofId); ` + sql += `UPDATE ${names.guards} SET present=1 WHERE proofId=${context}.provenTxId AND EXISTS(SELECT 1 FROM proven_txs WHERE provenTxId=${context}.provenTxId); UPDATE ${names.keys} SET present=1 WHERE tableId=1 AND rowId=${context}.provenTxId AND EXISTS(SELECT 1 FROM proven_txs WHERE provenTxId=${context}.provenTxId); ` + } + return sql + } + + function numericMembership(d: IdentityDefinition, event: Event): string { + const { table, key, owner } = d.source + const context = event === 'DELETE' ? 'OLD' : 'NEW' + const retired = + event === 'DELETE' + ? `SELECT OLD.${q(key)} AS ${q(key)}${owner ? ',OLD.' + q(owner) + ' AS ' + q(owner) : ''}` + : displacedIdentity(d, event === 'UPDATE') + const ids = `SELECT ${q(key)} FROM (${retired})` + const owners = owner ? `SELECT ${q(owner)},${q(key)} FROM (${retired})` : '' + const sql = + profileMembership(table, key, context, owners) + + parentMembership(table, key, context, owners) + + globalMembership(table, context, ids) + return sql + finishIdentity(d, event) + } + + function compositeMembership(table: string, event: Event): string { + const context = event === 'DELETE' ? 'OLD' : 'NEW' + const contexts = event === 'UPDATE' ? ['OLD', 'NEW'] : [context] + if (table === 'certificate_fields') { + let sql = contexts + .map( + value => + `DELETE FROM ${names.certificate} WHERE snapshotFieldName=${value}.fieldName AND snapshotCertificateId=${value}.certificateId; ` + ) + .join('') + sql += fieldInsert( + `SELECT userId,fieldName,certificateId,1 FROM certificate_fields WHERE fieldName=${context}.fieldName AND certificateId=${context}.certificateId`, + 1 + ) + sql += fieldInsert( + `SELECT c.userId,f.fieldName,f.certificateId,2 FROM certificate_fields f JOIN certificates c ON c.certificateId=f.certificateId WHERE f.fieldName=${context}.fieldName AND f.certificateId=${context}.certificateId`, + 2 + ) + return sql + } + const id = relations.findIndex(relation => relation.table === table) + const relation = relations[id] + let sql = contexts + .map( + value => + `DELETE FROM ${names.relation} WHERE snapshotTableId=${id} AND snapshotLeftId=${value}.${relation.leftKey} AND snapshotRightId=${value}.${relation.rightKey}; ` + ) + .join('') + for (const side of ['left', 'right'] as const) { + const key = side === 'left' ? relation.leftKey : relation.rightKey + const bit = side === 'left' ? 1 : 2 + sql += relationInsert( + `SELECT ${id},p.userId,m.${relation.leftKey},m.${relation.rightKey},${bit} FROM ${table} m JOIN ${relation[side]} p ON p.${key}=m.${key} WHERE m.${relation.leftKey}=${context}.${relation.leftKey} AND m.${relation.rightKey}=${context}.${relation.rightKey}`, + bit + ) + } + return sql + } + + return { numeric: numericMembership, composite: compositeMembership } +} + +function changed(columns: string[]): string { + return [...new Set(columns)] + .map(column => `CAST(OLD.${q(column)} AS BLOB) IS NOT CAST(NEW.${q(column)} AS BLOB)`) + .join(' OR ') +} + +export function membershipTriggers(definitions: IdentityDefinition[], names: MembershipNames = legacyNames): string[] { + const sql: string[] = [] + const bodies = membershipBodies(names) + for (const d of definitions) { + const fields = d.columns.map(column => column.name) + if (d.source.table === 'transactions') fields.push('txid', 'provenTxId') + if (d.source.table === 'proven_tx_reqs') fields.push('provenTxId') + for (const event of ['INSERT', 'UPDATE', 'DELETE'] as const) { + const when = event === 'UPDATE' ? ' WHEN ' + changed(fields) : '' + if (event !== 'DELETE') + sql.push( + `CREATE TRIGGER ${q('snapshot_identity_before_' + d.source.table + '_' + event)} BEFORE ${event} ON ${q(d.source.table)}${when} BEGIN ${observeIdentity(d, event === 'UPDATE')} END` + ) + sql.push( + `CREATE TRIGGER ${q('snapshot_identity_after_' + d.source.table + '_' + event)} AFTER ${event} ON ${q(d.source.table)}${when} BEGIN ${bodies.numeric(d, event)} END` + ) + } + } + for (const table of [...relations.map(relation => relation.table), 'certificate_fields']) { + const relation = relations.find(relation => relation.table === table) + const fields = relation ? [relation.leftKey, relation.rightKey] : ['fieldName', 'certificateId', 'userId'] + for (const event of ['INSERT', 'UPDATE', 'DELETE'] as const) { + const when = event === 'UPDATE' ? ' WHEN ' + changed(fields) : '' + sql.push( + `CREATE TRIGGER ${q('snapshot_identity_after_' + table + '_' + event)} AFTER ${event} ON ${q(table)}${when} BEGIN ${bodies.composite(table, event)} END` + ) + } + } + return sql +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts index 7a023c8e1..cad78b74d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/ConcurrentSnapshotArchiveSource.test.ts @@ -67,7 +67,7 @@ test('a ready request waits for owned reader destruction while foreground storag const source = (await storage.openSnapshotArchiveSource(identity))! expect(source.user.identityKey).toBe(identity) expect(source.sourceStorage.storageIdentityKey).toBe('original-source') - expect(source.sourceSchema).toBe('2026-10-01-006 add snapshot global reference indexes') + expect(source.sourceSchema).toBe('2026-10-02-001 repair snapshot SQLite conflict maintenance') const reader = Reflect.get(storage, 'snapshotSyncSource') as StorageKnex expect(reader.knex).not.toBe(storage.knex) expect(reader.knex.client.config.pool).toMatchObject({ min: 0, max: 1 }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts index e528627b5..1f56b1342 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts @@ -1,3 +1,4 @@ +import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' import { removeSnapshotCertificateIndexes, SNAPSHOT_CERTIFICATE_INDEX_MIGRATION @@ -31,7 +32,9 @@ const timestamp = { created_at: date, updated_at: date } const stores: StorageKnex[] = [] const directories: string[] = [] -async function fixture(): Promise<{ source: StorageKnex; writer: StorageKnex; userId: number; otherId: number }> { +async function fixture( + historicalIndexes = false +): Promise<{ source: StorageKnex; writer: StorageKnex; userId: number; otherId: number }> { const directory = await mkdtemp(join(tmpdir(), 'wallet-keyset-')) directories.push(directory) const open = () => { @@ -50,7 +53,8 @@ async function fixture(): Promise<{ source: StorageKnex; writer: StorageKnex; us } const source = open() await source.knex.raw('PRAGMA journal_mode = WAL') - await source.migrate('keyset source', 'source-storage') + if (historicalIndexes) await migrateBeforeSqliteGeneration(source, 'keyset source', 'source-storage') + else await source.migrate('keyset source', 'source-storage') await source.makeAvailable() const { user } = await source.findOrInsertUser(identity) const { user: other } = await source.findOrInsertUser(foreignIdentity) @@ -578,7 +582,7 @@ test.each([ ])( 'SQL keys support forward seeks without OFFSET or counts (profile=%s, relation=%s, certificate=%s)', async (profileIndexes, relationIndexes, certificateIndexes) => { - const { source, userId, otherId } = await fixture() + const { source, userId, otherId } = await fixture(true) if (!profileIndexes) { await removeSnapshotProfileIndexes(source.knex) await source.knex('knex_migrations').where('name', SNAPSHOT_PROFILE_INDEX_MIGRATION).delete() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index 12a6df5ab..d273f5337 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -1,3 +1,4 @@ +import { readGenerationIndexState, type SnapshotIndexState } from '../schema/snapshotSqliteIndexState' import { readSnapshotGlobalIndexState } from '../schema/snapshotGlobalIndexMigration' import { readSnapshotCertificateIndexState } from '../schema/snapshotCertificateIndexMigration' import { readSnapshotRelationIndexState } from '../schema/snapshotRelationIndexMigration' @@ -32,15 +33,27 @@ interface TableDefinition { // storage-key order, not the canonical BRC-38 array order. const definitions: Record = { provenTxs: { name: 'proven_txs', keys: ['provenTxId'] }, - provenTxReqs: { name: 'proven_tx_reqs', keys: ['provenTxReqId'], booleans: ['notified', 'wasBroadcast'] }, + provenTxReqs: { + name: 'proven_tx_reqs', + keys: ['provenTxReqId'], + booleans: ['notified', 'wasBroadcast'] + }, outputBaskets: { name: 'output_baskets', keys: ['basketId'], booleans: ['isDeleted'] }, transactions: { name: 'transactions', keys: ['transactionId'], booleans: ['isOutgoing'] }, commissions: { name: 'commissions', keys: ['commissionId'], booleans: ['isRedeemed'] }, outputs: { name: 'outputs', keys: ['outputId'], booleans: ['spendable', 'change'] }, outputTags: { name: 'output_tags', keys: ['outputTagId'], booleans: ['isDeleted'] }, - outputTagMaps: { name: 'output_tags_map', keys: ['outputTagId', 'outputId'], booleans: ['isDeleted'] }, + outputTagMaps: { + name: 'output_tags_map', + keys: ['outputTagId', 'outputId'], + booleans: ['isDeleted'] + }, txLabels: { name: 'tx_labels', keys: ['txLabelId'], booleans: ['isDeleted'] }, - txLabelMaps: { name: 'tx_labels_map', keys: ['txLabelId', 'transactionId'], booleans: ['isDeleted'] }, + txLabelMaps: { + name: 'tx_labels_map', + keys: ['txLabelId', 'transactionId'], + booleans: ['isDeleted'] + }, certificates: { name: 'certificates', keys: ['certificateId'], booleans: ['isDeleted'] }, certificateFields: { name: 'certificate_fields', keys: ['fieldName', 'certificateId'] }, syncStates: { name: 'sync_states', keys: ['syncStateId'], booleans: ['init'], dates: ['when'] } @@ -57,7 +70,10 @@ const auxiliaryTableIds: Partial> = { syncStates: 7 } -const auxiliaryRelationTableIds: Partial> = { txLabelMaps: 0, outputTagMaps: 1 } +const auxiliaryRelationTableIds: Partial> = { + txLabelMaps: 0, + outputTagMaps: 1 +} function definition(table: WalletSnapshotTable): TableDefinition { if (!Object.hasOwn(definitions, table)) throw new WERR_INVALID_PARAMETER('table', 'a wallet snapshot table') @@ -131,12 +147,16 @@ function owned(k: Knex, table: string, id: string, source: string, userId: numbe .whereRaw('?? = ??', [`${table}.${id}`, source]) } -const globalTableIds: Partial> = { provenTxReqs: 0, provenTxs: 1 } +const globalTableIds: Partial> = { + provenTxReqs: 0, + provenTxs: 1 +} function relationSourceQuery( k: Knex, table: WalletSnapshotTable, userId: number, - relationId: number + relationId: number, + state: SnapshotIndexState ): Knex.QueryBuilder { const { name } = definitions[table] @@ -146,7 +166,9 @@ function relationSourceQuery( // auxiliary primary key whose suffix is the unchanged cursor order. const relationKeys = String(k.client.config.client).includes('mysql') ? k.raw('?? FORCE INDEX (??)', ['snapshot_relation_keys', 'PRIMARY']) - : 'snapshot_relation_keys' + : state === 'v2' + ? 'snapshot_relation_keys_v2' + : 'snapshot_relation_keys' const query = k(relationKeys) .crossJoin(name, function () { void this.on('snapshotLeftId', '=', `${name}.${left}`).andOn('snapshotRightId', '=', `${name}.${right}`) @@ -158,13 +180,15 @@ function relationSourceQuery( void query.whereBetween('snapshotMembership', [1, 3]).hintComment(['JOIN_FIXED_ORDER()', `JOIN_INDEX(${name})`]) return query } -function certificateSourceQuery(k: Knex, userId: number): Knex.QueryBuilder { +function certificateSourceQuery(k: Knex, userId: number, state: SnapshotIndexState): Knex.QueryBuilder { const name = 'certificate_fields' const mysql = String(k.client.config.client).includes('mysql') const keys = mysql ? k.raw('?? FORCE INDEX (??)', ['snapshot_certificate_field_keys', 'PRIMARY']) - : 'snapshot_certificate_field_keys' + : state === 'v2' + ? 'snapshot_certificate_field_keys_v2' + : 'snapshot_certificate_field_keys' const query = k(keys) .crossJoin(name, function () { void this.on('snapshotFieldName', '=', `${name}.fieldName`).andOn( @@ -180,13 +204,21 @@ function certificateSourceQuery(k: Knex, userId: number): Knex.QueryBuilder { .hintComment(['JOIN_FIXED_ORDER()', 'JOIN_INDEX(certificate_fields)']) return query } -function globalSourceQuery(k: Knex, table: WalletSnapshotTable, userId: number, globalId: number): Knex.QueryBuilder { +function globalSourceQuery( + k: Knex, + table: WalletSnapshotTable, + userId: number, + globalId: number, + state: SnapshotIndexState +): Knex.QueryBuilder { const { name } = definitions[table] const mysql = String(k.client.config.client).includes('mysql') const keys = mysql ? k.raw('?? FORCE INDEX (??)', ['snapshot_global_keys', 'snapshot_global_page']) - : 'snapshot_global_keys' + : state === 'v2' + ? 'snapshot_global_keys_v2' + : 'snapshot_global_keys' const query = k(keys) .crossJoin(name, 'rowId', `${name}.${definitions[table].keys[0]}`) .where({ tableId: globalId, userId, present: 1 }) @@ -200,23 +232,24 @@ export function walletSnapshotSourceQuery( k: Knex, table: WalletSnapshotTable, userId: number, - profileIndexes = false, - relationIndexes = false, - certificateIndexes = false, - globalIndexes = false + profileIndexes: SnapshotIndexState = false, + relationIndexes: SnapshotIndexState = false, + certificateIndexes: SnapshotIndexState = false, + globalIndexes: SnapshotIndexState = false ): Knex.QueryBuilder { const { name } = definitions[table] const tableId = auxiliaryTableIds[table] if (profileIndexes && tableId !== undefined) - return k('snapshot_profile_keys') + return k(profileIndexes === 'v2' ? 'snapshot_profile_keys_v2' : 'snapshot_profile_keys') .crossJoin(name, 'snapshotRowId', `${name}.${definitions[table].keys[0]}`) .where({ snapshotTableId: tableId, snapshotUserId: userId }) .where(`${name}.userId`, userId) const relationId = auxiliaryRelationTableIds[table] - if (relationIndexes && relationId !== undefined) return relationSourceQuery(k, table, userId, relationId) - if (certificateIndexes && table === 'certificateFields') return certificateSourceQuery(k, userId) + if (relationIndexes && relationId !== undefined) + return relationSourceQuery(k, table, userId, relationId, relationIndexes) + if (certificateIndexes && table === 'certificateFields') return certificateSourceQuery(k, userId, certificateIndexes) const globalId = globalTableIds[table] - if (globalIndexes && globalId !== undefined) return globalSourceQuery(k, table, userId, globalId) + if (globalIndexes && globalId !== undefined) return globalSourceQuery(k, table, userId, globalId, globalIndexes) const query = k(name) if (table === 'provenTxReqs') { return query.whereExists(owned(k, 'transactions', 'txid', `${name}.txid`, userId)) @@ -323,10 +356,10 @@ interface SnapshotContext { storage: StorageKnex userId: number snapshotId: string - profileIndexes: boolean - relationIndexes: boolean - certificateIndexes: boolean - globalIndexes: boolean + profileIndexes: SnapshotIndexState + relationIndexes: SnapshotIndexState + certificateIndexes: SnapshotIndexState + globalIndexes: SnapshotIndexState columns: Map } @@ -410,19 +443,35 @@ async function readPage( const raw: Array> = await query rows = raw.map(row => normalize(storage, row, schema)) } - return { rows, payloadBytes, cursor, done: count === candidates.length && candidates.length < limits.maxRows } + return { + rows, + payloadBytes, + cursor, + done: count === candidates.length && candidates.length < limits.maxRows + } } +// Keep the existing optional positional call contract while grouping the four +// index-generation selections captured by the same retained view. +type SnapshotIndexFlags = [ + profileIndexes?: SnapshotIndexState, + relationIndexes?: SnapshotIndexState, + certificateIndexes?: SnapshotIndexState, + globalIndexes?: SnapshotIndexState +] + /** Bind every page to one provider-owned view and immutable profile identifiers. */ export function createKnexWalletSnapshotPageReader( storage: StorageKnex, userId: number, snapshotId: string, view: RetainedReadSnapshot, - profileIndexes = false, - relationIndexes = false, - certificateIndexes = false, - globalIndexes = false + ...[ + profileIndexes = false, + relationIndexes = false, + certificateIndexes = false, + globalIndexes = false + ]: SnapshotIndexFlags ): WalletReadSnapshot['readPage'] { const context: SnapshotContext = { storage, @@ -460,21 +509,24 @@ export async function openKnexWalletReadSnapshot( try { const { header, profileIndexes, relationIndexes, certificateIndexes, globalIndexes } = await view.read( async trx => { + const generation = await readGenerationIndexState(storage.toDb(trx), storage.knex.client.config.migrations) const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') return { header: { sourceStorage, user }, - profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - relationIndexes: await readSnapshotRelationIndexState( - storage.toDb(trx), - storage.knex.client.config.migrations - ), - globalIndexes: await readSnapshotGlobalIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - certificateIndexes: await readSnapshotCertificateIndexState( - storage.toDb(trx), - storage.knex.client.config.migrations - ) + profileIndexes: + generation ?? + (await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), + relationIndexes: + generation ?? + (await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), + globalIndexes: + generation ?? + (await readSnapshotGlobalIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), + certificateIndexes: + generation ?? + (await readSnapshotCertificateIndexState(storage.toDb(trx), storage.knex.client.config.migrations)) } } ) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.integration.test.ts index 5fbf20251..2bdd745f1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotCertificateIndexes.integration.test.ts @@ -1,3 +1,4 @@ +import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -33,7 +34,7 @@ async function fixture() { const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) stores.push(source) await k.raw('PRAGMA journal_mode=WAL') - await source.migrate('certificate index fixture', 'synthetic-certificate-index') + await migrateBeforeSqliteGeneration(source, 'certificate index fixture', 'synthetic-certificate-index') await source.makeAvailable() // Start with the immediately preceding schema on both old and new source. await removeSnapshotCertificateIndexes(k) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.integration.test.ts index 7f32e0839..96c14f600 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotGlobalIndexes.integration.test.ts @@ -1,3 +1,4 @@ +import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -32,7 +33,7 @@ async function fixture() { const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) stores.push(source) await k.raw('PRAGMA journal_mode=WAL') - await source.migrate('global index fixture', 'synthetic-global-index') + await migrateBeforeSqliteGeneration(source, 'global index fixture', 'synthetic-global-index') await source.makeAvailable() // Start with the immediately preceding schema on both old and new source. await removeSnapshotGlobalIndexes(k) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts index 58f8d6add..63d5d7ffa 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.integration.test.ts @@ -1,3 +1,4 @@ +import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -33,7 +34,7 @@ async function fixture() { const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) stores.push(source) await k.raw('PRAGMA journal_mode=WAL') - await source.migrate('profile index fixture', 'synthetic-profile-index') + await migrateBeforeSqliteGeneration(source, 'profile index fixture', 'synthetic-profile-index') await source.makeAvailable() // Start with the immediately preceding schema on both old and new source. await removeSnapshotProfileIndexes(k) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts index e826379ba..b920fe426 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotProfileIndexes.migration.test.ts @@ -1,3 +1,4 @@ +import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -26,7 +27,7 @@ test('registered profile bootstrap survives reopening and publishes its journal expect((await migrationSource.getMigration(SNAPSHOT_PROFILE_INDEX_MIGRATION)).config).toEqual({ transaction: false }) - await source.migrate('profile migration', 'synthetic-profile-migration') + await migrateBeforeSqliteGeneration(source, 'profile migration', 'synthetic-profile-migration') await source.makeAvailable() await database.migrate.down({ migrationSource, name: SNAPSHOT_PROFILE_INDEX_MIGRATION, disableTransactions: false }) const priorJournal = await database('knex_migrations').orderBy('id') @@ -49,7 +50,7 @@ test('registered profile bootstrap survives reopening and publishes its journal } database.on('query', interrupt) try { - await expect(source.migrate('profile migration', 'synthetic-profile-migration')).rejects.toBe(failure) + await expect(migrateBeforeSqliteGeneration(source, 'profile migration', 'synthetic-profile-migration')).rejects.toBe(failure) } finally { database.off('query', interrupt) } @@ -64,7 +65,7 @@ test('registered profile bootstrap survives reopening and publishes its journal await source.destroy() database = knex(options) source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: database }) - await expect(source.migrate('profile migration', 'synthetic-profile-migration')).resolves.toBe( + await expect(migrateBeforeSqliteGeneration(source, 'profile migration', 'synthetic-profile-migration')).resolves.toBe( SNAPSHOT_GLOBAL_INDEX_MIGRATION ) expect(await readSnapshotProfileIndexState(database)).toBe(true) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.integration.test.ts index 990e444df..5eaca3df3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotRelationIndexes.integration.test.ts @@ -1,3 +1,4 @@ +import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -33,7 +34,7 @@ async function fixture() { const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) stores.push(source) await k.raw('PRAGMA journal_mode=WAL') - await source.migrate('relation index fixture', 'synthetic-relation-index') + await migrateBeforeSqliteGeneration(source, 'relation index fixture', 'synthetic-relation-index') await source.makeAvailable() // Start with the immediately preceding schema on both old and new source. await removeSnapshotRelationIndexes(k) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteActualSchema.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteActualSchema.test.ts new file mode 100644 index 000000000..b9165a96b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteActualSchema.test.ts @@ -0,0 +1,128 @@ +import { knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { names } from '../schema/snapshotSqliteIndexGeneration' +import { legacyNames } from '../schema/snapshotSqliteMembership' +import type { Knex } from 'knex' +import { exact, replace, tables } from '../../../test/utils/snapshotSqliteFixtures' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { seedArchiveClosure } from '../../../test/utils/snapshotArchiveFixtures' +import { snapshotArchiveTables } from './archive/SnapshotArchive' +import { walletSnapshotSourceQuery } from './KnexWalletReadSnapshot' +import { openKnexSnapshotArchiveSource } from './archive/KnexSnapshotArchiveSource' +import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' +import type { SnapshotArchiveSource } from './archive/KnexSnapshotArchiveSource' + +async function pages(view: WalletReadSnapshot | SnapshotArchiveSource) { + const result: Record = {} + try { + if ('validateClosure' in view) await view.validateClosure() + for (const table of snapshotArchiveTables) { + let cursor: WalletSnapshotCursor | undefined + const rows: unknown[] = [] + for (let n = 0; n < 30; n++) { + const page = await view.readPage(table, cursor, { maxRows: 1, maxBytes: 131072 }) + rows.push(...page.rows) + if (page.done) { + result[table] = rows + break + } + cursor = page.cursor + } + expect(result[table]).toBeDefined() + } + return result + } finally { + await view.close() + } +} + +test.each([false, true])( + 'actual migrated wallet preserves replacement membership and both readers, recursive=%s', + async recursive => { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-identity-actual-')) + const k = knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + const identities = ['02' + '11'.repeat(32), '03' + '22'.repeat(32)] + try { + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('identity fixture', 'synthetic-identity-fixture') + await source.makeAvailable() + await k.raw('PRAGMA recursive_triggers=' + Number(recursive)) + const users = [] + for (const identity of identities) users.push((await source.findOrInsertUser(identity)).user.userId) + await seedArchiveClosure(source, users[0], users[1]) + const originalSource = await k('sqlite_master') + .whereIn('tbl_name', tables) + .whereIn('type', ['table', 'index']) + .orderBy('name') + const mapping = new Map( + Object.entries(legacyNames).map(([key, value]) => [value, names[key as keyof typeof names]]) + ) + const rebuilt = new Proxy(k, { + apply(target, self, args: unknown[]) { + if (typeof args[0] === 'string' && mapping.has(args[0])) args[0] = mapping.get(args[0]) + return Reflect.apply(target, self, args) + } + }) as Knex + expect( + await k('sqlite_master').whereIn('tbl_name', tables).whereIn('type', ['table', 'index']).orderBy('name') + ).toEqual(originalSource) + await exact(rebuilt) + // Move both source rows of one profile, retaining all PKs and foreign keys. + // Each REPLACE is legal with foreign_keys enabled; closure agrees at the end. + for (const table of tables) { + const rows = await k(table) + for (const row of rows) { + if ('userId' in row && row.userId === users[0]) row.userId = users[1] + await replace(k, table, row) + await exact(rebuilt) + } + } + expect((await k.raw('PRAGMA foreign_keys'))[0].foreign_keys).toBe(1) + expect(await k.raw('PRAGMA foreign_key_check')).toEqual([]) + for (const [index, userId] of users.entries()) { + for (const table of snapshotArchiveTables) { + const name = + ( + { + provenTxs: 'proven_txs', + provenTxReqs: 'proven_tx_reqs', + outputBaskets: 'output_baskets', + outputTags: 'output_tags', + outputTagMaps: 'output_tags_map', + txLabels: 'tx_labels', + txLabelMaps: 'tx_labels_map', + certificateFields: 'certificate_fields', + syncStates: 'sync_states' + } as Record + )[table] ?? table + const direct = await walletSnapshotSourceQuery(k, table, userId).select(name + '.*') + const indexed = await walletSnapshotSourceQuery(k, table, userId, 'v2', 'v2', 'v2', 'v2').select(name + '.*') + const order = (rows: unknown[]) => rows.map(row => JSON.stringify(row)).sort() + expect(order(indexed)).toEqual(order(direct)) + } + const ordinary = await pages(await source.openWalletReadSnapshot(identities[index])) + const archive = await pages(await openKnexSnapshotArchiveSource(source, identities[index])) + expect(Object.fromEntries(Object.entries(ordinary).map(([name, rows]) => [name, rows.length]))).toEqual( + Object.fromEntries(Object.entries(archive).map(([name, rows]) => [name, rows.length])) + ) + } + // A secondary-unique conflict with no child rows may replace a different PK. + const original = await k('sync_states').where('syncStateId', 1).first() + await replace(k, 'sync_states', { ...original, syncStateId: 101 }) + expect(await k('sync_states').where('syncStateId', 1)).toEqual([]) + await exact(rebuilt) + } finally { + await source.destroy() + await rm(directory, { recursive: true, force: true }) + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteAllFamilies.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteAllFamilies.test.ts new file mode 100644 index 000000000..87439c9a7 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteAllFamilies.test.ts @@ -0,0 +1,88 @@ +import fc from 'fast-check' +import { fixture, exact, value, keyOf, replace, tables } from '../../../test/utils/snapshotSqliteFixtures' + +test.each([false, true])( + 'all families retain exact memberships through replacements and nested ownership, recursive=%s', + async recursive => { + const k = await fixture('BINARY', recursive) + try { + for (const table of tables) for (const id of [1, 2]) await k(table).insert(value(table, id, id, id)) + await exact(k) + await k.raw( + 'CREATE TRIGGER nested_transaction_owner AFTER INSERT ON transactions WHEN NEW.userId=2 BEGIN UPDATE transactions SET userId=3 WHERE transactionId=NEW.transactionId; END' + ) + await replace(k, 'transactions', value('transactions', 1, 2, 2)) + await exact(k) + for (const table of tables) { + await replace(k, table, value(table, 1, 1, 3)) + await exact(k) + const query = k(table) + .where(keyOf(table, 2, 2)) + .update(value(table, 1, 1, 3)) + .toSQL() + await k.raw(query.sql.replace(/^update/i, 'UPDATE OR IGNORE'), query.bindings) + await exact(k) + } + } finally { + await k.destroy() + } + } +) + +test.each(['BINARY', 'NOCASE', 'RTRIM'])( + 'all-family independent source oracle survives %s conflict schedules', + async collation => { + const k = await fixture(collation, false) + try { + await fc.assert( + fc.asyncProperty( + fc.boolean(), + fc.array( + fc.record({ + table: fc.integer({ min: 0, max: 12 }), + kind: fc.integer({ min: 0, max: 4 }), + id: fc.integer({ min: 1, max: 6 }), + other: fc.integer({ min: 1, max: 6 }), + user: fc.integer({ min: 1, max: 3 }) + }), + { minLength: 1, maxLength: 12 } + ), + async (recursive, operations) => { + await k.raw('PRAGMA recursive_triggers=' + Number(recursive)) + for (const table of [...tables].reverse()) await k(table).delete() + await exact(k) + for (const op of operations) { + const table = tables[op.table] + if (op.kind === 0) await replace(k, table, value(table, op.id, op.other, op.user)) + else if (op.kind === 1) + await k(table) + .where(keyOf(table, op.id, op.other)) + .delete() + else if (op.kind < 4) { + const query = k(table) + .where(keyOf(table, op.id, op.other)) + .update(value(table, op.other, op.id, op.user)) + .toSQL() + await k.raw( + query.sql.replace(/^update/i, op.kind === 2 ? 'UPDATE OR REPLACE' : 'UPDATE OR IGNORE'), + query.bindings + ) + } else + await k.transaction(async trx => { + await trx(table) + .where(keyOf(table, op.id, op.other)) + .delete() + await trx.rollback() + }) + await exact(k) + } + } + ), + { numRuns: 300, seed: 3242026 } + ) + } finally { + await k.destroy() + } + }, + 30000 +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteFileWal.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteFileWal.test.ts new file mode 100644 index 000000000..d749c65f1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteFileWal.test.ts @@ -0,0 +1,51 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fixture, exact, value, replace, tables } from '../../../test/utils/snapshotSqliteFixtures' +import { installMembershipDraft } from '../../../test/utils/snapshotSqliteMaintenanceFixture' + +test.each([false, true])( + 'independent WAL writer and pinned reader survive trigger replacement, recursive=%s', + async recursive => { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-identity-wal-')) + const filename = join(directory, 'fixture.sqlite') + const source = await fixture('BINARY', true, false, filename) + const writer = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + let view: Knex.Transaction | undefined + try { + await writer.raw('PRAGMA recursive_triggers=' + Number(recursive)) + for (const table of tables) for (const id of [1, 2]) await source(table).insert(value(table, id, id, id)) + await exact(source) + view = await source.transaction() + const beforeRows = await view('transactions').orderBy('transactionId') + const beforeKeys = await view('snapshot_profile_keys').orderBy([ + 'snapshotTableId', + 'snapshotUserId', + 'snapshotRowId' + ]) + await installMembershipDraft(writer) + await replace(writer, 'transactions', value('transactions', 1, 2, 3)) + await exact(writer) + expect(await view('transactions').orderBy('transactionId')).toEqual(beforeRows) + expect( + await view('snapshot_profile_keys').orderBy(['snapshotTableId', 'snapshotUserId', 'snapshotRowId']) + ).toEqual(beforeKeys) + expect(await view('sqlite_master').where('name', 'snapshot_identity_transactions')).toEqual([]) + await view.commit() + view = undefined + expect(await source('transactions').orderBy('transactionId')).toEqual([value('transactions', 1, 2, 3)]) + await exact(source) + } finally { + if (view !== undefined) await view.rollback() + await writer.destroy() + await source.destroy() + await rm(directory, { recursive: true, force: true }) + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGeneration.test.ts new file mode 100644 index 000000000..f78791b38 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGeneration.test.ts @@ -0,0 +1,174 @@ +import { readGenerationIndexState, migration } from '../schema/snapshotSqliteIndexState' +import { dropGenerationForDataDeletion } from '../schema/snapshotSqliteIndexMigration' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import type { Knex } from 'knex' +import { fixture, exact, value, replace, tables } from '../../../test/utils/snapshotSqliteFixtures' +import { installGeneration, names, metadata, progress, oldProgress } from '../schema/snapshotSqliteIndexGeneration' +import { copyGenerationPage } from '../schema/snapshotSqliteIndexBootstrap' +import { legacyNames } from '../schema/snapshotSqliteMembership' + +function generation(k: Knex): Knex { + const mapping = new Map(Object.entries(legacyNames).map(([key, value]) => [value, names[key as keyof typeof names]])) + return new Proxy(k, { + apply(target, self, args: unknown[]) { + if (typeof args[0] === 'string' && mapping.has(args[0])) args[0] = mapping.get(args[0]) + return Reflect.apply(target, self, args) + } + }) +} +async function finish(k: Knex, plan: Awaited>) { + for (let page = 0; page < 100; page++) if ((await copyGenerationPage(k, plan)).complete) return + throw new Error('Rebuild did not finish') +} + +test.each(['BINARY', 'NOCASE', 'RTRIM'])( + 'fresh %s generation repairs stale indexes while low-key writes continue', + async collation => { + const k = await fixture(collation, false, false) + try { + for (const table of tables) for (const id of [1, 2]) await k(table).insert(value(table, id, id, id)) + await replace(k, 'transactions', value('transactions', 1, 2, 3)) + await expect(exact(k)).rejects.toThrow() + const source = await k('transactions') + const plan = await installGeneration(k) + expect(await k('transactions')).toEqual(source) + for (const table of oldProgress) expect((await k(table)).every(row => row.complete === 0)).toBe(true) + let pages = 0 + for (; pages < 100; pages++) { + const result = await copyGenerationPage(k, plan) + if (result.complete) break + await replace(k, 'transactions', value('transactions', 1, 2, (pages % 2) + 1)) + await replace(k, 'certificates', value('certificates', 1, 1, (pages % 2) + 1)) + await replace(k, 'proven_tx_reqs', value('proven_tx_reqs', 1, 2, (pages % 2) + 1)) + } + expect(pages).toBe(12) + await exact(generation(k)) + expect(await k(metadata).first('complete')).toEqual({ complete: 1 }) + expect(await installGeneration(k)).toEqual(plan) + await finish(k, plan) + await exact(generation(k)) + } finally { + await k.destroy() + } + } +) + +test('page rollback keeps its cursor and memberships together; resumed copies are bounded', async () => { + const k = await fixture('BINARY', false, false) + try { + for (let start = 0; start < 600; start += 100) + await k('transactions').insert( + Array.from({ length: 100 }, (_, i) => value('transactions', start + i + 1, start + i + 1, 1)) + ) + const plan = await installGeneration(k) + const failure = new Error('synthetic checkpoint write failure') + const inject = (query: { sql: string }) => { + if (query.sql.startsWith('update `' + progress + '`')) throw failure + } + k.on('query', inject) + try { + await expect(copyGenerationPage(k, plan)).rejects.toBe(failure) + } finally { + k.off('query', inject) + } + expect(await k(names.profile)).toEqual([]) + expect(await k(progress).where('stream', 0).first()).toMatchObject({ afterId: 0, complete: 0 }) + const first = await copyGenerationPage(k, plan) + expect(first.copiedThrough).toMatchObject({ afterId: 256, complete: 0 }) + expect(await k(names.profile)).toHaveLength(256) + await replace(k, 'transactions', value('transactions', 1, 2, 2)) + const resumed = await installGeneration(k) + expect((await copyGenerationPage(k, resumed)).copiedThrough).toMatchObject({ + afterId: 512, + complete: 0 + }) + expect((await copyGenerationPage(k, resumed)).copiedThrough).toMatchObject({ + afterId: 600, + complete: 1 + }) + await finish(k, resumed) + await exact(generation(k)) + } finally { + await k.destroy() + } +}) + +test.each(['tx_labels_map', 'output_tags_map', 'certificate_fields'])( + 'unsupported additional unique constraint on %s refuses atomically', + async table => { + const k = await fixture('BINARY', false, false) + try { + const column = table === 'tx_labels_map' ? 'transactionId' : table === 'output_tags_map' ? 'outputId' : 'userId' + await k.raw('CREATE UNIQUE INDEX unexpected_identity ON ??(??)', [table, column]) + const before = await k('sqlite_master').orderBy('name') + await expect(installGeneration(k)).rejects.toThrow('Unsupported composite identity constraints') + expect(await k('sqlite_master').orderBy('name')).toEqual(before) + } finally { + await k.destroy() + } + } +) + +test('changed source schema and missing owned triggers refuse resume without adopting the mismatch', async () => { + const k = await fixture('BINARY', false, false) + try { + const plan = await installGeneration(k) + await k.raw('CREATE INDEX unexpected_source_index ON tx_labels(userId)') + await expect(installGeneration(k)).rejects.toThrow('Rebuild source binding mismatch') + await k.raw('DROP INDEX unexpected_source_index') + await k.raw('DROP TRIGGER snapshot_identity_before_transactions_INSERT') + await expect(installGeneration(k)).rejects.toThrow('Rebuild schema definition mismatch') + expect(await k(metadata).first('complete')).toEqual({ complete: 0 }) + expect(plan.source).not.toBe('') + } finally { + await k.destroy() + } +}) + +test.each([ + 'missing progress', + 'extra progress', + 'negative cursor', + 'wrong text cursor', + 'partial composite cursor', + 'invalid completion', + 'wrong source binding', + 'negative retirement', + 'fractional retirement', + 'excess retirement', + 'published pending', + 'completed with pending streams', + 'missing lock', + 'changed lock' +])('malformed generation state refuses adoption: %s', async kind => { + const k = await fixture('BINARY', false, false) + try { + await k('transactions').insert(value('transactions', 1, 1, 1)) + await installGeneration(k) + if (kind === 'missing progress') await k(progress).where('stream', 0).delete() + if (kind === 'extra progress') + await k(progress).insert({ stream: 12, afterId: 0, afterSecond: 0, afterText: '', complete: 0 }) + if (kind === 'negative cursor') await k(progress).where('stream', 0).update({ afterId: -1 }) + if (kind === 'wrong text cursor') await k(progress).where('stream', 0).update({ afterText: 'unexpected' }) + if (kind === 'partial composite cursor') await k(progress).where('stream', 8).update({ afterId: 1 }) + if (kind === 'invalid completion') await k(progress).where('stream', 0).update({ complete: 2 }) + if (kind === 'wrong source binding') await k(metadata).update({ source: 'changed' }) + if (kind === 'negative retirement') await k(metadata).update({ retireTable: -1 }) + if (kind === 'fractional retirement') await k(metadata).update({ retireTable: 0.5 }) + if (kind === 'excess retirement') await k(metadata).update({ retireTable: 7 }) + if (kind === 'published pending') + await k('knex_migrations').insert({ name: migration, batch: 99, migration_time: new Date() }) + if (kind === 'completed with pending streams') await k(metadata).update({ complete: 1 }) + if (kind === 'missing lock') await k.schema.dropTable('snapshot_index_install_lock_v2') + if (kind === 'changed lock') await k.raw('ALTER TABLE snapshot_index_install_lock_v2 ADD COLUMN extra INTEGER') + const before = await k('transactions') + await expect(readGenerationIndexState(k)).rejects.toBeInstanceOf(WERR_INVALID_OPERATION) + if (kind === 'missing lock' || kind === 'changed lock') { + await expect(dropGenerationForDataDeletion(k)).rejects.toBeInstanceOf(WERR_INVALID_OPERATION) + expect(await k.schema.hasTable(names.profile)).toBe(true) + } + expect(await k('transactions')).toEqual(before) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationCost.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationCost.test.ts new file mode 100644 index 000000000..36693e27f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationCost.test.ts @@ -0,0 +1,208 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { retireGenerationPage } from '../schema/snapshotSqliteIndexRetirement' +import { fixture, value, tables } from '../../../test/utils/snapshotSqliteFixtures' +import { installGeneration, names, metadata } from '../schema/snapshotSqliteIndexGeneration' +import { copyGenerationPage } from '../schema/snapshotSqliteIndexBootstrap' +import { walletSnapshotSourceQuery } from './KnexWalletReadSnapshot' +import type { WalletSnapshotTable } from './WalletReadSnapshot' + +const selections: Array<{ + table: WalletSnapshotTable + keys: string[] + after: (number | string)[] +}> = [ + { table: 'transactions', keys: ['snapshotRowId'], after: [4999] }, + { table: 'txLabelMaps', keys: ['snapshotLeftId', 'snapshotRightId'], after: [4999, 4999] }, + { table: 'outputTagMaps', keys: ['snapshotLeftId', 'snapshotRightId'], after: [4999, 4999] }, + { + table: 'certificateFields', + keys: ['snapshotFieldName', 'snapshotCertificateId'], + after: ['field-4999', 4999] + }, + { table: 'provenTxs', keys: ['rowId'], after: [4999] }, + { table: 'provenTxReqs', keys: ['rowId'], after: [4999] } +] + +test('all generation page shapes use indexed profile/key ranges without a temporary sort', async () => { + const k = await fixture('BINARY', false, false) + try { + await installGeneration(k) + for (const selection of selections) { + const query = walletSnapshotSourceQuery(k, selection.table, 1, 'v2', 'v2', 'v2', 'v2').select('*') + if (selection.keys.length === 1) void query.where(selection.keys[0], '>', selection.after[0]) + else void query.whereRaw('(??,??)>(?,?)', [...selection.keys, ...selection.after]) + void query.orderBy(selection.keys).limit(32) + const sql = query.toSQL() + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + sql.sql, sql.bindings) + expect(plan.some(step => step.detail.includes('SEARCH snapshot_') && step.detail.includes('_v2'))).toBe(true) + expect( + plan.filter(step => step.detail.includes('TEMP B-TREE') || step.detail.startsWith('SCAN snapshot_')) + ).toEqual([]) + } + } finally { + await k.destroy() + } +}) + +test('no-op and unrelated source updates do not amplify auxiliary writes', async () => { + const k = await fixture('BINARY', false, false) + try { + await k.schema.alterTable('transactions', table => table.text('description').notNullable().defaultTo('')) + const plan = await installGeneration(k) + await k('transactions').insert(value('transactions', 1, 1, 1)) + for (let i = 0; i < 30; i++) if ((await copyGenerationPage(k, plan)).complete) break + const total = async () => Number((await k.raw('SELECT total_changes() AS writes'))[0].writes) + const before = await total() + await k('transactions').where('transactionId', 1).update({ userId: 1, reference: 'r1', txid: 't1', provenTxId: 1 }) + expect((await total()) - before).toBe(1) + const same = await total() + await k('transactions').where('transactionId', 1).update({ description: 'updated payload' }) + expect((await total()) - same).toBe(1) + expect(await k(names.profile)).toHaveLength(1) + expect(await k(names.edges)).toHaveLength(1) + } finally { + await k.destroy() + } +}) + +test.each(['transactions', 'tx_labels_map', 'certificate_fields'])( + 'invalid leading %s identity cannot be skipped by the initial cursor', + async table => { + const k = await fixture('BINARY', false, false) + try { + const plan = await installGeneration(k) + const row = + table === 'transactions' + ? { ...value(table, 1, 1, 1), transactionId: -1 } + : table === 'tx_labels_map' + ? { txLabelId: -1, transactionId: 1 } + : { userId: 1, certificateId: -1, fieldName: '', fieldValue: 'v' } + await k(table).insert(row) + let rejected = false + for (let i = 0; i < 30; i++) { + try { + const result = await copyGenerationPage(k, plan) + if (result.complete) break + } catch (error) { + expect(error).toBeInstanceOf(Error) + rejected = true + break + } + } + expect(rejected).toBe(true) + expect(await k(metadata).first('complete')).toEqual({ complete: 0 }) + } finally { + await k.destroy() + } + } +) + +test('populated WAL measurement records bounded rebuild, indexed late pages and reusable retired space', async () => { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-generation-measure-')) + const filename = join(directory, 'fixture.sqlite') + const k = await fixture('BINARY', false, false, filename) + const started = performance.now() + const footprint = async () => { + const base = await stat(filename) + const wal = await stat(filename + '-wal').catch(() => undefined) + return { + databaseBytes: base.size, + walBytes: wal?.size ?? 0, + pageCount: Number((await k.raw('PRAGMA page_count'))[0].page_count), + freePages: Number((await k.raw('PRAGMA freelist_count'))[0].freelist_count) + } + } + try { + for (const table of tables) + for (let start = 0; start < 1000; start += 100) { + const rows = Array.from({ length: 100 }, (_, index) => { + const id = start + index + 1 + return table === 'certificate_fields' + ? { userId: (id % 2) + 1, fieldName: 'a', certificateId: id, fieldValue: 'v' } + : value(table, id, id, (id % 2) + 1) + }) + await k(table).insert(rows) + } + const before = await footprint() + const copyStart = performance.now(), + plan = await installGeneration(k) + let peak = before.databaseBytes + before.walBytes, + copyPages = 0, + retirePages = 0, + maximumRetired = 0 + for (; copyPages < 100; copyPages++) { + const page = await copyGenerationPage(k, plan) + const now = await footprint() + peak = Math.max(peak, now.databaseBytes + now.walBytes) + if (page.complete) { + copyPages++ + break + } + } + expect(copyPages).toBe(49) + const copied = await footprint(), + copyMilliseconds = performance.now() - copyStart + const plans: Record = {} + for (const selected of selections) { + const after = selected.table === 'certificateFields' ? ['a', 900] : selected.keys.map(() => 900) + const query = walletSnapshotSourceQuery(k, selected.table, 1, 'v2', 'v2', 'v2', 'v2').select('*') + if (selected.keys.length === 1) void query.where(selected.keys[0], '>', after[0]) + else void query.whereRaw('(??,??)>(?,?)', [...selected.keys, ...after]) + void query.orderBy(selected.keys).limit(8) + const sql = query.toSQL(), + rows = await query + expect(rows).toHaveLength(8) + const steps: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + sql.sql, sql.bindings) + expect(steps.some(step => step.detail.includes('SEARCH snapshot_') && step.detail.includes('_v2'))).toBe(true) + expect( + steps.filter(step => step.detail.includes('TEMP B-TREE') || step.detail.startsWith('SCAN snapshot_')) + ).toEqual([]) + plans[selected.table] = steps + } + const retireStart = performance.now() + for (; retirePages < 200; retirePages++) { + const page = await retireGenerationPage(k, plan) + maximumRetired = Math.max(maximumRetired, page.removed) + expect(page.removed).toBeLessThanOrEqual(256) + const now = await footprint() + peak = Math.max(peak, now.databaseBytes + now.walBytes) + if (page.complete) { + retirePages++ + break + } + } + expect(retirePages).toBeLessThan(200) + const retired = await footprint() + expect(retired.freePages).toBeGreaterThan(copied.freePages) + const receipt = { + observedAt: new Date().toISOString(), + fixture: '1000 small rows in each of thirteen minimal standard source tables; two profiles', + production: false, + sourceRows: 13000, + sqliteVersion: (await k.raw('SELECT sqlite_version() AS version'))[0].version, + before, + copied, + retired, + peakObservedDatabaseAndWalBytes: peak, + copyPages, + retirePages, + maximumRetired, + copyMilliseconds, + retireMilliseconds: performance.now() - retireStart, + totalMilliseconds: performance.now() - started, + plans, + limitations: [ + 'fixed minimal records, not a complete large-wallet performance or resource-budget acceptance', + 'SQL range plans and returned-row counts do not measure internal VM row visits', + 'freed SQLite pages are reusable; database/WAL file shrink is not promised' + ] + } + console.log(JSON.stringify({ sqliteGenerationCost: receipt })) + } finally { + await k.destroy() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationMigration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationMigration.test.ts new file mode 100644 index 000000000..a1bee1510 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationMigration.test.ts @@ -0,0 +1,56 @@ +import { fixture, value } from '../../../test/utils/snapshotSqliteFixtures' +import { migrateGeneration, refuseGenerationDowngrade } from '../schema/snapshotSqliteIndexMigration' +import { migration, readGenerationIndexState } from '../schema/snapshotSqliteIndexState' +import { names } from '../schema/snapshotSqliteIndexGeneration' +import { retiredTables } from '../schema/snapshotSqliteLegacyOwnership' + +test('registered-style migration yields between bounded pages and never auto-publishes its journal', async () => { + const k = await fixture('BINARY', false, false) + let timer: ReturnType | undefined + try { + for (let start = 0; start < 600; start += 100) + await k('transactions').insert( + Array.from({ length: 100 }, (_, i) => value('transactions', start + i + 1, start + i + 1, 1)) + ) + const source = await k('transactions').orderBy('transactionId') + let turns = 0, + active = true + const foreground = () => { + turns++ + if (active) timer = setTimeout(foreground, 0) + } + timer = setTimeout(foreground, 0) + try { + await migrateGeneration(k) + } finally { + active = false + if (timer) clearTimeout(timer) + } + expect(turns).toBeGreaterThan(2) + expect(await k('transactions').orderBy('transactionId')).toEqual(source) + expect(await k(names.profile)).toHaveLength(600) + for (const table of retiredTables) expect(await k.schema.hasTable(table)).toBe(false) + expect(await readGenerationIndexState(k)).toBe(false) + await k('knex_migrations').insert({ name: migration, batch: 99, migration_time: new Date() }) + expect(await readGenerationIndexState(k)).toBe('v2') + await migrateGeneration(k) + await expect(refuseGenerationDowngrade(k)).rejects.toThrow('downgrade is unsupported') + expect(await k('transactions').orderBy('transactionId')).toEqual(source) + } finally { + if (timer) clearTimeout(timer) + await k.destroy() + } +}) + +test('an outer SQLite transaction refuses before any schema edit', async () => { + const k = await fixture('BINARY', false, false) + try { + const before = await k('sqlite_master').orderBy('name') + await k.transaction(async trx => { + await expect(migrateGeneration(trx)).rejects.toThrow('independent bounded transactions') + }) + expect(await k('sqlite_master').orderBy('name')).toEqual(before) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationReaders.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationReaders.test.ts new file mode 100644 index 000000000..ba7f36892 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationReaders.test.ts @@ -0,0 +1,142 @@ +import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' +import { retireGenerationPage } from '../schema/snapshotSqliteIndexRetirement' +import { knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { installGeneration } from '../schema/snapshotSqliteIndexGeneration' +import { copyGenerationPage } from '../schema/snapshotSqliteIndexBootstrap' +import { readGenerationIndexState, migration } from '../schema/snapshotSqliteIndexState' +import { replace } from '../../../test/utils/snapshotSqliteFixtures' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { seedArchiveClosure } from '../../../test/utils/snapshotArchiveFixtures' +import { snapshotArchiveTables } from './archive/SnapshotArchive' +import { openKnexSnapshotArchiveSource } from './archive/KnexSnapshotArchiveSource' +import { readSnapshotProfileIndexState } from '../schema/snapshotProfileIndexMigration' +import type { WalletReadSnapshot, WalletSnapshotCursor } from './WalletReadSnapshot' +import type { SnapshotArchiveSource } from './archive/KnexSnapshotArchiveSource' + +async function pages(view: WalletReadSnapshot | SnapshotArchiveSource) { + const result: Record = {} + try { + if ('validateClosure' in view) await view.validateClosure() + for (const table of snapshotArchiveTables) { + let cursor: WalletSnapshotCursor | undefined + const rows: unknown[] = [] + for (let n = 0; n < 30; n++) { + const page = await view.readPage(table, cursor, { maxRows: 1, maxBytes: 131072 }) + rows.push(...page.rows) + if (page.done) { + result[table] = rows + break + } + cursor = page.cursor + } + expect(result[table]).toBeDefined() + } + return result + } finally { + await view.close() + } +} + +test.each(['ordinary', 'archive'])( + '%s pins legacy, pending and completed generations across independent WAL writes', + async kind => { + const directory = await mkdtemp(join(tmpdir(), 'snapshot-generation-reader-')) + const options = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + } + const k = knex(options), + writer = knex(options) + const legacySource = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex(options) + }) + let oldest: WalletReadSnapshot | SnapshotArchiveSource | undefined + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + const identity = '02' + '11'.repeat(32) + let pinned: WalletReadSnapshot | SnapshotArchiveSource | undefined + const open = async () => + kind === 'ordinary' + ? await source.openWalletReadSnapshot(identity) + : await openKnexSnapshotArchiveSource(source, identity) + try { + await k.raw('PRAGMA journal_mode=WAL') + await migrateBeforeSqliteGeneration(source, 'generation-reader', 'synthetic-generation-reader') + await source.makeAvailable() + const user = (await source.findOrInsertUser(identity)).user.userId + const other = (await source.findOrInsertUser('03' + '22'.repeat(32))).user.userId + await seedArchiveClosure(source, user, other) + const baseline = await pages(await open()) + pinned = await open() + await legacySource.makeAvailable() + oldest = + kind === 'ordinary' + ? await legacySource.openWalletReadSnapshot(identity) + : await openKnexSnapshotArchiveSource(legacySource, identity) + const plan = await installGeneration(writer) + const original = await writer('sync_states').where('syncStateId', 1).first() + await replace(writer, 'sync_states', { ...original, syncStateId: 101 }) + expect(await pages(pinned)).toEqual(baseline) + pinned = undefined + await expect(readSnapshotProfileIndexState(writer)).rejects.toThrow('incomplete') + expect(await readGenerationIndexState(writer)).toBe(false) + const pending = await pages(await open()) + expect(pending.syncStates.map(row => (row as { syncStateId: number }).syncStateId)).toEqual([3, 101]) + pinned = await open() + for (let i = 0; i < 50; i++) if ((await copyGenerationPage(writer, plan)).complete) break + // Complete local copy is not published as an indexed source before journaling. + expect(await readGenerationIndexState(writer)).toBe(false) + await writer('knex_migrations').insert({ + name: migration, + batch: 99, + migration_time: new Date() + }) + expect(await readGenerationIndexState(writer)).toBe('v2') + let retired = false + for (let i = 0; i < 100; i++) + if ((await retireGenerationPage(writer, plan)).complete) { + retired = true + break + } + expect(retired).toBe(true) + expect(await pages(oldest!)).toEqual(baseline) + oldest = undefined + await writer('sync_states').insert({ + ...original, + syncStateId: 102, + refNum: 'new-independent-state' + }) + expect(await pages(pinned)).toEqual(pending) + pinned = undefined + const queries: string[] = [] + const listen = (query: { sql: string }) => { + if (query.sql.includes('cross join') && query.sql.includes('_v2')) queries.push(query.sql) + } + k.on('query', listen) + try { + const readyView = await open() + if ('sourceSchema' in readyView) expect(readyView.sourceSchema).toBe(migration) + const ready = await pages(readyView) + expect(ready.syncStates.map(row => (row as { syncStateId: number }).syncStateId)).toEqual([3, 101, 102]) + expect(queries.length).toBeGreaterThan(0) + for (const table of snapshotArchiveTables.filter(table => table !== 'syncStates')) + expect(ready[table]).toEqual(baseline[table]) + } finally { + k.off('query', listen) + } + } finally { + if (oldest) await oldest.close() + if (pinned) await pinned.close() + await legacySource.destroy() + await writer.destroy() + await source.destroy() + await rm(directory, { recursive: true, force: true }) + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts new file mode 100644 index 000000000..afb7a0841 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts @@ -0,0 +1,116 @@ +import { knex, type Knex } from 'knex' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { KnexMigrations, SNAPSHOT_SQLITE_INDEX_MIGRATION } from '../schema/KnexMigrations' +import { installGeneration, metadata, progress, names } from '../schema/snapshotSqliteIndexGeneration' +import { copyGenerationPage } from '../schema/snapshotSqliteIndexBootstrap' +import { readGenerationIndexState } from '../schema/snapshotSqliteIndexState' +import { + migrateGeneration, + refuseGenerationDowngrade, + dropGenerationForDataDeletion +} from '../schema/snapshotSqliteIndexMigration' +import { retiredTables } from '../schema/snapshotSqliteLegacyOwnership' +import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' + +function fixture(tableName = 'knex_migrations') { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + migrations: { tableName } + }) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + return { k, source } +} +async function seed(source: StorageKnex, count: number) { + await source.makeAvailable() + const userId = (await source.findOrInsertUser('02' + '11'.repeat(32))).user.userId + for (let start = 0; start < count; start += 100) { + await source.knex('tx_labels').insert( + Array.from({ length: Math.min(100, count - start) }, (_, i) => ({ + userId, + label: `generation-${start + i}`, + isDeleted: false, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01') + })) + ) + } +} + +test.each(['knex_migrations', 'custom_snapshot_journal'])( + 'registered migration resumes and publishes %s only after bounded copy/retirement', + async tableName => { + const { k, source } = fixture(tableName) + try { + await migrateBeforeSqliteGeneration(source, 'registry', 'synthetic-registry') + await seed(source, 600) + const before = await k('tx_labels').orderBy('txLabelId') + let pages = 0 + const failure = new Error('interrupt second generation source page') + const stop = (q: { sql: string }) => { + if (q.sql.startsWith('select `txLabelId` as `rowId`') && ++pages === 2) throw failure + } + k.on('query', stop) + try { + await expect(source.migrate('registry', 'synthetic-registry')).rejects.toBe(failure) + } finally { + k.off('query', stop) + } + expect(pages).toBe(2) + expect(await k(progress).where('stream', 3).first('afterId')).toEqual({ afterId: 256 }) + expect(await k(tableName).where('name', SNAPSHOT_SQLITE_INDEX_MIGRATION)).toEqual([]) + expect(await readGenerationIndexState(k, { tableName })).toBe(false) + expect((await k.raw('PRAGMA foreign_keys'))[0].foreign_keys).toBe(1) + await expect(source.migrate('registry', 'synthetic-registry')).resolves.toBe(SNAPSHOT_SQLITE_INDEX_MIGRATION) + expect(await readGenerationIndexState(k, { tableName })).toBe('v2') + expect(await k('tx_labels').orderBy('txLabelId')).toEqual(before) + expect(await k(names.profile).where('snapshotTableId', 3)).toHaveLength(600) + for (const table of retiredTables) expect(await k.schema.hasTable(table)).toBe(false) + expect(await k(tableName).where('name', SNAPSHOT_SQLITE_INDEX_MIGRATION)).toHaveLength(1) + await expect(source.migrate('registry', 'synthetic-registry')).resolves.toBe(SNAPSHOT_SQLITE_INDEX_MIGRATION) + const migrationSource = new KnexMigrations('test', 'registry', 'synthetic-registry', 1024) + expect(migrationSource.migrations[SNAPSHOT_SQLITE_INDEX_MIGRATION].config).toEqual({ transaction: false }) + await expect(k.migrate.down({ migrationSource, name: SNAPSHOT_SQLITE_INDEX_MIGRATION })).rejects.toThrow( + 'downgrade is unsupported' + ) + expect(await k('tx_labels').orderBy('txLabelId')).toEqual(before) + expect(await readGenerationIndexState(k, { tableName })).toBe('v2') + } finally { + await source.destroy() + } + } +) + +test.each(['complete', 'unpublished'] as const)( + 'explicit dropAllData preserves its contract for a %s generation', + async phase => { + const { k, source } = fixture() + try { + await migrateBeforeSqliteGeneration(source, 'deletion', 'synthetic-deletion') + await seed(source, 3) + if (phase === 'complete') await source.migrate('deletion', 'synthetic-deletion') + else await copyGenerationPage(k, await installGeneration(k)) + expect(await k.schema.hasTable(metadata)).toBe(true) + await source.dropAllData() + expect(await k.schema.hasTable(metadata)).toBe(false) + expect(await k.schema.hasTable('snapshot_index_install_lock_v2')).toBe(false) + expect(await k.schema.hasTable('users')).toBe(false) + expect(await k.schema.hasTable('tx_labels')).toBe(false) + expect(await k('knex_migrations')).toEqual([]) + expect(await k('sqlite_master').where('name', 'like', 'snapshot_%')).toEqual([]) + await source.dropAllData() + } finally { + await source.destroy() + } + } +) + +test('MySQL retains its existing migration and deletion behavior without SQLite operations', async () => { + const database = { client: { config: { client: 'mysql2' } } } as Knex + await migrateGeneration(database) + await refuseGenerationDowngrade(database) + await dropGenerationForDataDeletion(database) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRetirement.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRetirement.test.ts new file mode 100644 index 000000000..8ed98a548 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRetirement.test.ts @@ -0,0 +1,153 @@ +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { fixture, value } from '../../../test/utils/snapshotSqliteFixtures' +import { installGeneration, names, metadata } from '../schema/snapshotSqliteIndexGeneration' +import { copyGenerationPage } from '../schema/snapshotSqliteIndexBootstrap' +import { retireGenerationPage } from '../schema/snapshotSqliteIndexRetirement' +import { retiredTables } from '../schema/snapshotSqliteLegacyOwnership' + +test('obsolete generation cleanup is bounded, rollback-safe, resumable and preserves source data', async () => { + const k = await fixture('BINARY', false, false) + try { + for (let start = 0; start < 600; start += 100) + await k('transactions').insert( + Array.from({ length: 100 }, (_, i) => value('transactions', start + i + 1, start + i + 1, 1)) + ) + const source = await k('transactions').orderBy('transactionId') + const plan = await installGeneration(k) + await expect(retireGenerationPage(k, plan)).rejects.toThrow('before generation copy') + for (let i = 0; i < 100; i++) if ((await copyGenerationPage(k, plan)).complete) break + expect(await retireGenerationPage(k, plan)).toMatchObject({ + removed: 256, + complete: false, + table: retiredTables[0] + }) + expect(await k(retiredTables[0])).toHaveLength(344) + const failure = new Error('synthetic retirement rollback') + const inject = (_response: unknown, query: { sql: string }) => { + if (query.sql.startsWith('delete from `snapshot_global_edges`')) throw failure + } + k.on('query-response', inject) + try { + await expect(retireGenerationPage(k, plan)).rejects.toBe(failure) + } finally { + k.off('query-response', inject) + } + expect(await k(retiredTables[0])).toHaveLength(344) + const resumed = await installGeneration(k) + let completed = false + for (let i = 0; i < 100; i++) + if ((await retireGenerationPage(k, resumed)).complete) { + completed = true + break + } + expect(completed).toBe(true) + for (const table of retiredTables) expect(await k.schema.hasTable(table)).toBe(false) + expect(await k('transactions').orderBy('transactionId')).toEqual(source) + expect(await k(names.profile)).toHaveLength(600) + expect(await k(names.edges)).toHaveLength(600) + expect(await retireGenerationPage(k, resumed)).toEqual({ + complete: true, + removed: 0, + table: undefined + }) + } finally { + await k.destroy() + } +}) + +test('ownership drift refuses cleanup before deleting any legacy rows', async () => { + const k = await fixture('BINARY', false, false) + try { + await k('transactions').insert(value('transactions', 1, 1, 1)) + const plan = await installGeneration(k) + for (let i = 0; i < 100; i++) if ((await copyGenerationPage(k, plan)).complete) break + await k.raw( + 'CREATE TRIGGER foreign_old_edge AFTER DELETE ON snapshot_global_edges BEGIN DELETE FROM transactions; END' + ) + await expect(retireGenerationPage(k, plan)).rejects.toThrow('schema changed') + expect(await k('transactions')).toHaveLength(1) + expect(await k(metadata).first('retireTable')).toEqual({ retireTable: 0 }) + } finally { + await k.destroy() + } +}) + +test.each(['source-trigger', 'aux-trigger', 'reference-view'])( + 'unowned %s prevents replacement without changing schema', + async kind => { + const k = await fixture('BINARY', false, false) + try { + const sql = + kind === 'source-trigger' + ? 'CREATE TRIGGER snapshot_profile_custom AFTER INSERT ON transactions BEGIN SELECT 1; END' + : kind === 'aux-trigger' + ? 'CREATE TRIGGER foreign_owned_aux AFTER DELETE ON snapshot_global_edges BEGIN DELETE FROM transactions; END' + : 'CREATE VIEW foreign_aux_view AS SELECT * FROM snapshot_profile_keys' + await k.raw(sql) + const before = await k('sqlite_master').orderBy('name') + await expect(installGeneration(k)).rejects.toThrow( + /Unknown legacy source trigger|Unowned object references legacy auxiliary data|Unknown legacy auxiliary trigger/ + ) + expect(await k('sqlite_master').orderBy('name')).toEqual(before) + } finally { + await k.destroy() + } + } +) + +test('shared logical rowId values across key families still retire at most256 physical rows', async () => { + const k = await fixture('BINARY', false, false) + try { + for (let start = 0; start < 300; start += 100) { + await k('transactions').insert( + Array.from({ length: 100 }, (_, i) => value('transactions', start + i + 1, start + i + 1, 1)) + ) + await k('proven_tx_reqs').insert( + Array.from({ length: 100 }, (_, i) => value('proven_tx_reqs', start + i + 1, start + i + 1, 1)) + ) + } + const plan = await installGeneration(k) + for (let i = 0; i < 100; i++) if ((await copyGenerationPage(k, plan)).complete) break + let before = 0, + removed = 0 + for (let i = 0; i < 100; i++) { + before = Number((await k('snapshot_global_keys').count({ count: '*' }).first())!.count) + const page = await retireGenerationPage(k, plan) + if (page.table === 'snapshot_global_keys') { + removed = page.removed + expect(removed).toBe(256) + const after = Number((await k('snapshot_global_keys').count({ count: '*' }).first())!.count) + expect(before - after).toBe(256) + break + } + } + expect(before).toBe(600) + expect(removed).toBe(256) + expect(await k('transactions')).toHaveLength(300) + expect(await k(names.keys)).toHaveLength(600) + } finally { + await k.destroy() + } +}) + +test.each(['{', 'null', '[null]', '[{"type":"table","name":"foreign","tbl_name":"users","sql":null}]'])( + 'invalid retirement ownership receipt refuses before deletion: %s', + async legacy => { + const k = await fixture('BINARY', false, false) + try { + await k('transactions').insert(value('transactions', 1, 1, 1)) + const plan = await installGeneration(k) + let done = false + for (let n = 0; n < 50 && !done; n++) done = (await copyGenerationPage(k, plan)).complete + expect(done).toBe(true) + await k(metadata).update({ legacy }) + const before = await k('snapshot_global_edges') + await expect(retireGenerationPage(k, plan)).rejects.toBeInstanceOf(WERR_INVALID_OPERATION) + expect(await k('snapshot_global_edges')).toEqual(before) + expect(await k(metadata).first('retireTable')).toEqual({ retireTable: 0 }) + expect(await k('transactions')).toHaveLength(1) + } finally { + await k.destroy() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteIdentity.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteIdentity.test.ts new file mode 100644 index 000000000..873408509 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteIdentity.test.ts @@ -0,0 +1,312 @@ +import { copyIdentityPage } from '../../../test/utils/snapshotSqliteIdentityFixture' +import { knex, type Knex } from 'knex' +import fc from 'fast-check' +import { + readIdentity, + identityDDL, + observeIdentity, + displacedIdentity, + finishIdentity, + type SourceIdentity, + type IdentityDefinition +} from '../schema/snapshotSqliteIdentity' + +const sources: Array< + SourceIdentity & { + fields: string + unique: string[] + values: (id: number) => Record + } +> = [ + { + table: 'transactions', + key: 'transactionId', + owner: 'userId', + fields: 'reference varchar(64)', + unique: ['reference'], + values: id => ({ reference: 'r' + id }) + }, + { + table: 'proven_txs', + key: 'provenTxId', + fields: 'txid varchar(64)', + unique: ['txid'], + values: id => ({ txid: 't' + id }) + }, + { + table: 'proven_tx_reqs', + key: 'provenTxReqId', + fields: 'txid varchar(64)', + unique: ['txid'], + values: id => ({ txid: 't' + id }) + }, + { + table: 'outputs', + key: 'outputId', + owner: 'userId', + fields: 'transactionId integer, vout integer', + unique: ['transactionId,vout,userId'], + values: id => ({ transactionId: 1, vout: id }) + }, + { + table: 'certificates', + key: 'certificateId', + owner: 'userId', + fields: 'type varchar(100), certifier varchar(130), serialNumber varchar(100)', + unique: ['userId,type,certifier,serialNumber'], + values: id => ({ type: 'a', certifier: 'b', serialNumber: 's' + id }) + }, + { + table: 'tx_labels', + key: 'txLabelId', + owner: 'userId', + fields: 'label varchar(300)', + unique: ['label,userId'], + values: id => ({ label: 'l' + id }) + }, + { + table: 'output_tags', + key: 'outputTagId', + owner: 'userId', + fields: 'tag varchar(150)', + unique: ['tag,userId'], + values: id => ({ tag: 't' + id }) + }, + { + table: 'output_baskets', + key: 'basketId', + owner: 'userId', + fields: 'name varchar(300)', + unique: ['name,userId'], + values: id => ({ name: 'b' + id }) + }, + { + table: 'commissions', + key: 'commissionId', + owner: 'userId', + fields: 'transactionId integer', + unique: ['transactionId'], + values: id => ({ transactionId: id }) + }, + { + table: 'sync_states', + key: 'syncStateId', + owner: 'userId', + fields: 'refNum varchar(100)', + unique: ['refNum'], + values: id => ({ refNum: 'r' + id }) + } +] + +async function database(source: (typeof sources)[number], collation: string, recursive: boolean) { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + await k.raw('PRAGMA recursive_triggers=' + Number(recursive)) + const fields = source.fields.replaceAll(/varchar\(\d+\)/g, value => value + ' COLLATE ' + collation) + await k.raw( + `CREATE TABLE ${source.table}(${source.key} integer PRIMARY KEY${source.owner ? ',userId integer NOT NULL' : ''},${fields},${source.unique.map(value => 'UNIQUE(' + value + ')').join(',')})` + ) + const definition = await readIdentity(k, source) + for (const ddl of identityDDL(definition)) await k.raw(ddl) + await k.raw('CREATE TABLE observed(id integer,userId integer)') + return { k, definition } +} + +async function observers(k: Knex, d: IdentityDefinition) { + for (const event of ['INSERT', 'UPDATE', 'DELETE'] as const) { + if (event !== 'DELETE') { + await k.raw( + `CREATE TRIGGER witness_before_${event} BEFORE ${event} ON ${d.source.table} BEGIN ${observeIdentity(d, event === 'UPDATE')} END` + ) + } + const observed = + event === 'DELETE' + ? `INSERT INTO observed VALUES(OLD.${d.source.key},${d.source.owner ? 'OLD.' + d.source.owner : 'NULL'});` + : `INSERT INTO observed SELECT ${d.source.key},${d.source.owner ?? 'NULL'} FROM (${displacedIdentity(d, event === 'UPDATE')});` + await k.raw( + `CREATE TRIGGER witness_after_${event} AFTER ${event} ON ${d.source.table} BEGIN ${observed} ${finishIdentity(d, event)} END` + ) + } +} + +function row(source: (typeof sources)[number], id: number, owner = 1, unique = id) { + return { [source.key]: id, ...(source.owner ? { userId: owner } : {}), ...source.values(unique) } +} + +async function same(k: Knex, d: IdentityDefinition) { + const columns = d.columns.map(column => column.name) + expect(await k(d.table).select(columns).orderBy(d.source.key)).toEqual( + await k(d.source.table).select(columns).orderBy(d.source.key) + ) +} + +async function copy(k: Knex, d: IdentityDefinition) { + let after: number | undefined = 0 + do { + const current: number = after + after = await k.transaction(trx => copyIdentityPage(trx, d, current, 1)) + } while (after !== undefined) +} + +test.each(sources)('captures unbootstrapped primary and unique conflicts in $table', async source => { + for (const collation of ['BINARY', 'NOCASE', 'RTRIM']) + for (const recursive of [false, true]) { + const { k, definition: d } = await database(source, collation, recursive) + try { + await k(source.table).insert([row(source, 1), row(source, 2), row(source, 3)]) + await observers(k, d) + const replacement = row(source, 1, 1, 2) + const insert = k(source.table).insert(replacement).toSQL() + await k.raw(insert.sql.replace(/^insert/i, 'INSERT OR REPLACE'), insert.bindings) + expect(await k('observed').orderBy('id')).toEqual([1, 2].map(id => ({ id, userId: source.owner ? 1 : null }))) + await copy(k, d) + await same(k, d) + await k('observed').delete() + const update = k(source.table) + .where(source.key, 3) + .update({ [source.key]: 1 }) + .toSQL() + await k.raw(update.sql.replace(/^update/i, 'UPDATE OR IGNORE'), update.bindings) + expect(await k('observed')).toEqual([]) + await same(k, d) + } finally { + await k.destroy() + } + } +}) + +test.each(['BINARY', 'NOCASE', 'RTRIM'])( + 'identity schedules match source state under %s', + async collation => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: sources.length - 1 }), + fc.boolean(), + fc.array( + fc.record({ + kind: fc.integer({ min: 0, max: 5 }), + id: fc.integer({ min: 1, max: 6 }), + other: fc.integer({ min: 1, max: 6 }), + owner: fc.integer({ min: 1, max: 3 }) + }), + { minLength: 1, maxLength: 25 } + ), + async (index, recursive, operations) => { + const source = sources[index] + const { k, definition: d } = await database(source, collation, recursive) + try { + await observers(k, d) + for (const op of operations) { + const value = row(source, op.id, op.owner, op.other) + if (op.kind < 2) { + const query = k(source.table).insert(value).toSQL() + await k.raw( + query.sql.replace(/^insert/i, op.kind === 0 ? 'INSERT OR REPLACE' : 'INSERT OR IGNORE'), + query.bindings + ) + } else if (op.kind < 4) { + const query = k(source.table) + .where(source.key, op.id) + .update(row(source, op.other, op.owner, op.id)) + .toSQL() + await k.raw( + query.sql.replace(/^update/i, op.kind === 2 ? 'UPDATE OR REPLACE' : 'UPDATE OR IGNORE'), + query.bindings + ) + } else if (op.kind === 4) { + await k(source.table).where(source.key, op.id).delete() + } else { + await k.transaction(async trx => { + await trx(source.table).where(source.key, op.id).delete() + await trx.rollback() + }) + } + await same(k, d) + } + } finally { + await k.destroy() + } + } + ), + { numRuns: 300, seed: 3242026 } + ) + }, + 30000 +) + +test('nullable unique values do not identify unrelated rows', async () => { + const source = sources[0] + const { k, definition: d } = await database(source, 'BINARY', false) + try { + await observers(k, d) + await k(source.table).insert([ + { transactionId: 1, userId: 1, reference: null }, + { transactionId: 2, userId: 2, reference: null } + ]) + await same(k, d) + expect(await k('observed')).toEqual([]) + } finally { + await k.destroy() + } +}) + +test.each([ + ['BINARY', 'A', false], + ['NOCASE', 'A', true], + ['RTRIM', 'a ', true] +] as const)('unique comparison preserves exact spelling under %s', async (collation, spelling, displaced) => { + const { k, definition: d } = await database(sources[0], collation, false) + try { + await k('transactions').insert({ transactionId: 1, userId: 1, reference: 'a' }) + await observers(k, d) + await k.raw('INSERT OR REPLACE INTO transactions(transactionId,userId,reference) VALUES(2,2,?)', [spelling]) + expect(await k('observed')).toEqual(displaced ? [{ id: 1, userId: 1 }] : []) + await copy(k, d) + await same(k, d) + expect((await k(d.table).where('transactionId', 2).first()).reference).toBe(spelling) + } finally { + await k.destroy() + } +}) + +test('bootstrap refuses a caller without a transaction before copying rows', async () => { + const { k, definition: d } = await database(sources[0], 'BINARY', false) + try { + await k('transactions').insert(row(sources[0], 1)) + await expect(copyIdentityPage(k, d, 0)).rejects.toThrow('requires a transaction') + expect(await k(d.table)).toEqual([]) + } finally { + await k.destroy() + } +}) + +test.each([false, true])('nested ownership changes retain every displaced owner, recursive=%s', async recursive => { + for (const registerNestedFirst of [false, true]) { + const { k, definition: d } = await database(sources[0], 'BINARY', recursive) + try { + await k('transactions').insert(row(sources[0], 1, 1)) + const nested = () => + k.raw( + 'CREATE TRIGGER nested_owner AFTER INSERT ON transactions WHEN NEW.userId=2 BEGIN UPDATE transactions SET userId=3 WHERE transactionId=NEW.transactionId; END' + ) + if (registerNestedFirst) await nested() + await observers(k, d) + if (!registerNestedFirst) await nested() + await k.raw('INSERT OR REPLACE INTO transactions(transactionId,userId,reference) VALUES(1,2,?)', ['r1']) + expect(await k('observed')).toEqual( + expect.arrayContaining([ + { id: 1, userId: 1 }, + { id: 1, userId: 2 } + ]) + ) + await same(k, d) + expect((await k('transactions').first()).userId).toBe(3) + } finally { + await k.destroy() + } + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts index 38afb9bde..15c5c9f5c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveCapture.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { knex } from 'knex' import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' -import { SNAPSHOT_GLOBAL_INDEX_MIGRATION } from '../../schema/KnexMigrations' +import { SNAPSHOT_SQLITE_INDEX_MIGRATION } from '../../schema/KnexMigrations' import { decodeSyncTransfer } from '../../remoting/SyncTransfer' import * as Transfer from '../../remoting/SyncTransfer' import * as ArchiveSource from './KnexSnapshotArchiveSource' @@ -72,7 +72,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof const manifest = await captureKnexSnapshotArchive(reader, writer.knex, identity, 'test', { onProgress: p => progress.push(p) }) - expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_GLOBAL_INDEX_MIGRATION) + expect(manifest.binding.sourceSchema).toBe(SNAPSHOT_SQLITE_INDEX_MIGRATION) expect(manifest.binding.sourceStorage.storageName).toBe('original source') expect(manifest.binding.sourceStorage.storageIdentityKey).toBe('original-source') expect(manifest.binding.user).toMatchObject({ userId, identityKey: identity }) @@ -84,7 +84,7 @@ test('captures all thirteen tables with original metadata, packed bytes and prof sourceStorageIdentityKey: 'original-source', archiveId: manifest.archiveId, digest: manifest.digest, - sourceSchema: SNAPSHOT_GLOBAL_INDEX_MIGRATION + sourceSchema: SNAPSHOT_SQLITE_INDEX_MIGRATION }) expect(verified.manifest).toEqual(manifest) const captured: Record>> = {} @@ -153,7 +153,7 @@ test('source schema, primary history and closure stay pinned while an independen await writer.knex('users').where({ identityKey: identity }).update({ activeStorage: 'replacement' }) await writer.knex('knex_migrations').insert({ name: 'future-schema', batch: 99, migration_time: new Date() }) await writer.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) - expect(source.sourceSchema).toBe(SNAPSHOT_GLOBAL_INDEX_MIGRATION) + expect(source.sourceSchema).toBe(SNAPSHOT_SQLITE_INDEX_MIGRATION) expect(source.user.activeStorage).toBe(originalPrimary) await expect(source.validateClosure()).resolves.toBeUndefined() expect((await source.readPage('outputs')).rows[0].basketId).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts index 972cc3006..cab1129c2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveClosure.ts @@ -1,3 +1,4 @@ +import type { SnapshotIndexState } from '../../schema/snapshotSqliteIndexState' import type { Knex } from 'knex' import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' import { runInSeries } from '../../../utility/runInSeries' @@ -127,10 +128,10 @@ const references: readonly Reference[] = [ export async function assertKnexSnapshotArchiveClosure( k: Knex, userId: number, - profileIndexes = false, - relationIndexes = false, - certificateIndexes = false, - globalIndexes = false + profileIndexes: SnapshotIndexState = false, + relationIndexes: SnapshotIndexState = false, + certificateIndexes: SnapshotIndexState = false, + globalIndexes: SnapshotIndexState = false ): Promise { if (!Number.isSafeInteger(userId) || userId < 1) throw new WERR_INVALID_PARAMETER('userId', 'a positive safe ID') await runInSeries(references, async reference => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index b6072a9c5..5351a4487 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -1,3 +1,4 @@ +import { readGenerationIndexState } from '../../schema/snapshotSqliteIndexState' import { readSnapshotGlobalIndexState } from '../../schema/snapshotGlobalIndexMigration' import { readSnapshotCertificateIndexState } from '../../schema/snapshotCertificateIndexMigration' import { readSnapshotRelationIndexState } from '../../schema/snapshotRelationIndexMigration' @@ -56,6 +57,7 @@ export async function openKnexSnapshotArchiveSource( try { const { header, profileIndexes, relationIndexes, certificateIndexes, globalIndexes } = await view.read( async trx => { + const generation = await readGenerationIndexState(storage.toDb(trx), storage.knex.client.config.migrations) const sourceStorage = await storage.readSettings(trx) const user = await storage.findUserByIdentityKey(identityKey, trx) if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') @@ -65,16 +67,18 @@ export async function openKnexSnapshotArchiveSource( user, sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) }, - profileIndexes: await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - relationIndexes: await readSnapshotRelationIndexState( - storage.toDb(trx), - storage.knex.client.config.migrations - ), - globalIndexes: await readSnapshotGlobalIndexState(storage.toDb(trx), storage.knex.client.config.migrations), - certificateIndexes: await readSnapshotCertificateIndexState( - storage.toDb(trx), - storage.knex.client.config.migrations - ) + profileIndexes: + generation ?? + (await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), + relationIndexes: + generation ?? + (await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), + globalIndexes: + generation ?? + (await readSnapshotGlobalIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), + certificateIndexes: + generation ?? + (await readSnapshotCertificateIndexState(storage.toDb(trx), storage.knex.client.config.migrations)) } } ) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts index 2f966f674..bcae6af7f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.test.ts @@ -701,7 +701,7 @@ test('only acknowledged sequence positions are readable, even if an unacknowledg test('the auxiliary migration is registered after the durable sync schema', async () => { const migrations = new KnexMigrations('test', 'source', 'source', 1024) - expect(await migrations.getLatestMigration()).toBe('2026-10-01-006 add snapshot global reference indexes') + expect(await migrations.getLatestMigration()).toBe('2026-10-02-001 repair snapshot SQLite conflict maintenance') }) test('MySQL DDL accommodates the declared metadata and page byte ceilings', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts index 612c2002d..8eb26e5a2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttp.test.ts @@ -33,7 +33,7 @@ test.each([StorageClient, StorageMobile])( expect(storage.getSettings()).not.toHaveProperty('snapshotArchive') let transport = (await client.getSnapshotArchiveTransport(identityKey))! const offer = await transport.offer() - expect(offer.sourceSchema).toBe('2026-10-01-006 add snapshot global reference indexes') + expect(offer.sourceSchema).toBe('2026-10-02-001 repair snapshot SQLite conflict maintenance') expect(Math.abs(offer.serverTime - Date.now())).toBeLessThan(5000) const fields = { version: 1 as const, diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs index 41de44aa3..bdcf0c946 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveCrash.cjs @@ -171,6 +171,8 @@ async function main() { console.log(JSON.stringify({ globalIndexProcessLoss: await qualifySQLiteGlobalIndexProcessLoss() })) const { qualifySQLiteCertificateIndexProcessLoss } = require('./snapshotCertificateIndexCrash.cjs') console.log(JSON.stringify({ certificateIndexProcessLoss: await qualifySQLiteCertificateIndexProcessLoss() })) + const { qualifySQLiteGenerationProcessLoss } = require('./snapshotSqliteGenerationCrash.cjs') + console.log(JSON.stringify({ sqliteGenerationProcessLoss: await qualifySQLiteGenerationProcessLoss() })) } } main().catch(error => { diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index d723b974d..d321551e3 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -145,7 +145,7 @@ async function captureFixture() { assert.equal(manifest.pages, 14) assert.equal(manifest.binding.sourceStorage.storageIdentityKey, 'native-source') assert.equal(manifest.binding.user.activeStorage, 'historical selection') - assert.equal(manifest.binding.sourceSchema, '2026-10-01-006 add snapshot global reference indexes') + assert.equal(manifest.binding.sourceSchema, '2026-10-02-001 repair snapshot SQLite conflict maintenance') const store = new KnexSnapshotArchiveStore(writer.knex) const first = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 8)).bytes) const second = decodeSyncTransfer((await store.read(identity, manifest.archiveId, 9)).bytes) @@ -433,7 +433,7 @@ async function requestFixture(writer, reader) { sourceStorageIdentityKey: 'native-source', digest: ready.digest }) - assert.equal(verified.manifest.binding.sourceSchema, '2026-10-01-006 add snapshot global reference indexes') + assert.equal(verified.manifest.binding.sourceSchema, '2026-10-02-001 repair snapshot SQLite conflict maintenance') const page = await replacement.read(identity, ready.archiveId, 8) const decoded = decodeSyncTransfer(verifySnapshotArchivePage(page, verified.receipts[8])) assert.equal(decoded.rows[0].label, 'replacement') diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexCrash.cjs index e28b26eb5..155dd31b1 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotCertificateIndexCrash.cjs @@ -1,3 +1,4 @@ +const { migrateBeforeSqliteGeneration } = require('./snapshotHistoricalMigrations.cjs') // Synthetic process-loss qualification, invoked by the existing native fixtures. const assert = require('node:assert/strict') const { spawn } = require('node:child_process') @@ -36,7 +37,7 @@ async function seed(options) { const source = provider(options) try { if (options.client === 'better-sqlite3') await source.knex.raw('PRAGMA journal_mode = WAL') - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) await source.makeAvailable() const migrationSource = new KnexMigrations('test', migrationName, migrationIdentity, 1024) await source.knex.migrate.down({ @@ -113,7 +114,7 @@ async function child(options, phase, marker) { if (phase === 'after-commit' && cursorWritten && sql.startsWith('commit')) park('query-response') }) try { - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) throw new Error('Expected migration boundary was not reached') } finally { await source.destroy() @@ -181,7 +182,7 @@ async function qualify(options, phase) { // A killed migrator leaves Knex's lock claimed. This is fixture-owned recovery; // the verified child is gone and no other migrator can own this isolated store. await database.migrate.forceFreeMigrationsLock() - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) assert.equal(await readSnapshotCertificateIndexState(database), true) await oracle(database) assert.equal( diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexCrash.cjs index b46f97013..2c5c60672 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotGlobalIndexCrash.cjs @@ -1,3 +1,4 @@ +const { migrateBeforeSqliteGeneration } = require('./snapshotHistoricalMigrations.cjs') // Synthetic process-loss qualification, invoked by the existing native fixtures. const assert = require('node:assert/strict') const { spawn } = require('node:child_process') @@ -34,13 +35,13 @@ const provider = options => new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: knex(options) }) async function migrate(source) { - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) } async function seed(options) { const source = provider(options) try { if (options.client === 'better-sqlite3') await source.knex.raw('PRAGMA journal_mode = WAL') - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) await source.makeAvailable() await removeSnapshotGlobalIndexes(source.knex) await source.knex('knex_migrations').where('name', SNAPSHOT_GLOBAL_INDEX_MIGRATION).delete() diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotHistoricalMigrations.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotHistoricalMigrations.cjs new file mode 100644 index 000000000..bada5ecaf --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotHistoricalMigrations.cjs @@ -0,0 +1,16 @@ +// Native fixtures deliberately retain the schema whose migration they qualify. +const { KnexMigrations, SNAPSHOT_SQLITE_INDEX_MIGRATION } = require('../../out/src/storage/schema/KnexMigrations.js') +async function migrateBeforeSqliteGeneration(source, name, identity) { + const migrationSource = new KnexMigrations(source.chain, name, identity, 1024) + delete migrationSource.migrations[SNAPSHOT_SQLITE_INDEX_MIGRATION] + const config = { migrationSource, disableTransactions: false } + const sqlite = String(source.knex.client.config.client).includes('sqlite') + if (sqlite) await source.knex.raw('PRAGMA foreign_keys=OFF') + try { + await source.knex.migrate.latest(config) + return await source.knex.migrate.currentVersion(config) + } finally { + if (sqlite) await source.knex.raw('PRAGMA foreign_keys=ON') + } +} +module.exports = { migrateBeforeSqliteGeneration } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexCrash.cjs index 36a60d0af..8292df49d 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotProfileIndexCrash.cjs @@ -1,3 +1,4 @@ +const { migrateBeforeSqliteGeneration } = require('./snapshotHistoricalMigrations.cjs') // Synthetic process-loss qualification, invoked by the existing native fixtures. const assert = require('node:assert/strict') const { spawn } = require('node:child_process') @@ -28,7 +29,7 @@ async function seed(options) { const source = provider(options) try { if (options.client === 'better-sqlite3') await source.knex.raw('PRAGMA journal_mode = WAL') - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) await source.makeAvailable() const migrationSource = new KnexMigrations('test', migrationName, migrationIdentity, 1024) await source.knex.migrate.down({ @@ -85,7 +86,7 @@ async function child(options, phase, marker) { if (phase === 'after-commit' && cursorWritten && sql.startsWith('commit')) park('query-response') }) try { - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) throw new Error('Expected migration boundary was not reached') } finally { await source.destroy() @@ -151,7 +152,7 @@ async function qualify(options, phase) { // A killed migrator leaves Knex's lock claimed. This is fixture-owned recovery; // the verified child is gone and no other migrator can own this isolated store. await database.migrate.forceFreeMigrationsLock() - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) assert.equal(await readSnapshotProfileIndexState(database), true) await runInSeries(snapshotProfileTables.entries(), async ([tableId, { table, key }]) => { const expected = (await database(table).select('userId', key).orderBy(key)).map(row => ({ diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexCrash.cjs index cb0f2448f..c8b4d7a41 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotRelationIndexCrash.cjs @@ -1,3 +1,4 @@ +const { migrateBeforeSqliteGeneration } = require('./snapshotHistoricalMigrations.cjs') // Synthetic process-loss qualification, invoked by the existing native fixtures. const assert = require('node:assert/strict') const { spawn } = require('node:child_process') @@ -36,7 +37,7 @@ async function seed(options) { const source = provider(options) try { if (options.client === 'better-sqlite3') await source.knex.raw('PRAGMA journal_mode = WAL') - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) await source.makeAvailable() const migrationSource = new KnexMigrations('test', migrationName, migrationIdentity, 1024) await source.knex.migrate.down({ @@ -140,7 +141,7 @@ async function child(options, phase, marker) { if (phase === 'after-commit' && cursorWritten && sql.startsWith('commit')) park('query-response') }) try { - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) throw new Error('Expected migration boundary was not reached') } finally { await source.destroy() @@ -217,7 +218,7 @@ async function qualify(options, phase) { // A killed migrator leaves Knex's lock claimed. This is fixture-owned recovery; // the verified child is gone and no other migrator can own this isolated store. await database.migrate.forceFreeMigrationsLock() - await source.migrate(migrationName, migrationIdentity) + await migrateBeforeSqliteGeneration(source, migrationName, migrationIdentity) assert.equal(await readSnapshotRelationIndexState(database), true) await runInSeries(snapshotNumericRelations.entries(), async ([tableId, relation]) => { const left = new Map((await database(relation.left)).map(row => [row[relation.leftKey], row.userId])) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotSqliteGenerationCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotSqliteGenerationCrash.cjs new file mode 100644 index 000000000..c6a35c365 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotSqliteGenerationCrash.cjs @@ -0,0 +1,273 @@ +// Actual built-source, registered-migrator SQLite process-loss qualification. +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const fs = require('node:fs/promises') +const { writeFileSync } = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { migration, readGenerationIndexState } = require('../../out/src/storage/schema/snapshotSqliteIndexState.js') +const { names, metadata, progress } = require('../../out/src/storage/schema/snapshotSqliteIndexGeneration.js') +const { retiredTables } = require('../../out/src/storage/schema/snapshotSqliteLegacyOwnership.js') +const { migrateBeforeSqliteGeneration } = require('./snapshotHistoricalMigrations.cjs') +const { oracle } = require('./snapshotGlobalIndexFixtures.cjs') +const phases = [ + 'install-lock', + 'install-first-table', + 'install-partial-observers', + 'install-before-commit', + 'install-committed', + 'copy-before-cursor', + 'copy-after-cursor', + 'copy-before-commit', + 'copy-committed', + 'complete-before-commit', + 'copy-completed', + 'retirement-before-commit', + 'retirement-page-committed', + 'retirement-before-drop', + 'retirement-completed', + 'publication-before-insert', + 'publication-committed' +] +const dates = { created_at: new Date('2026-01-01'), updated_at: new Date('2026-01-01') } +function provider(filename) { + return new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + }) +} +async function seed(filename) { + const source = provider(filename) + try { + await source.knex.raw('PRAGMA journal_mode=WAL') + await migrateBeforeSqliteGeneration(source, 'generation loss', 'synthetic-generation-loss') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)) + const { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await source.knex('proven_txs').insert({ + ...dates, + provenTxId: 7, + txid: '7'.repeat(64), + height: 1, + index: 0, + merklePath: Buffer.from([1]), + rawTx: Buffer.from([1]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + await runInSeries([0, 100, 200, 300, 400, 500], async start => { + await source.knex('transactions').insert( + Array.from({ length: 100 }, (_, i) => ({ + ...dates, + transactionId: (start + i + 1) * 2, + userId: user.userId, + txid: 'a'.repeat(64), + provenTxId: 7, + status: 'completed', + reference: 'generation-' + (start + i), + isOutgoing: true, + satoshis: 0, + description: '' + })) + ) + }) + return { + userId: user.userId, + otherId: other.userId, + rows: await source.knex('transactions').orderBy('transactionId') + } + } finally { + await source.destroy() + } +} +async function child(filename, phase, marker) { + assert(phases.includes(phase)) + assert.equal(typeof process.send, 'function', 'Native fixture child requires its parent') + const source = provider(filename) + const k = source.knex + let stage = 'unstarted' + const park = observed => { + if (observed !== phase) return + writeFileSync(marker, JSON.stringify({ phase, stage }), { mode: 0o600 }) + process.kill(process.pid, 'SIGKILL') + } + let committing + const committed = () => { + const current = committing + committing = undefined + if (current === undefined) return + stage = 'idle' + if (current === 'install') park('install-committed') + if (current === 'copy') park('copy-committed') + if (current === 'complete') park('copy-completed') + if (current === 'retire') park('retirement-page-committed') + if (current === 'retire-complete') park('retirement-completed') + } + const firstCursor = q => q.sql.startsWith('update `snapshot_index_rebuild_v2`') && q.bindings.includes(512) + k.on('query', q => { + const sql = q.sql.toLowerCase() + // Knex does not emit query-response for every SQLite COMMIT. The next + // serialized query also proves that the prior transaction completed. + if (sql !== 'commit;') committed() + if (sql.startsWith('create table if not exists snapshot_index_install_lock_v2')) stage = 'install' + if (stage === 'install' && sql === 'commit;') park('install-before-commit') + if (firstCursor(q)) park('copy-before-cursor') + if (stage === 'copy' && sql === 'commit;') park('copy-before-commit') + if (stage === 'complete' && sql === 'commit;') park('complete-before-commit') + if (stage === 'retire' && sql === 'commit;') park('retirement-before-commit') + if (sql === 'commit;') committing = stage + if (sql.startsWith('drop table')) park('retirement-before-drop') + if (sql.startsWith('insert into `knex_migrations`') && q.bindings.includes(migration)) + park('publication-before-insert') + }) + k.on('query-response', (_value, q) => { + const sql = q.sql.toLowerCase() + if (sql.startsWith('create table if not exists snapshot_index_install_lock_v2')) park('install-lock') + if (sql.startsWith('create table "snapshot_profile_keys_v2"')) park('install-first-table') + if (sql.startsWith('create trigger "snapshot_identity_before_transactions_insert"')) + park('install-partial-observers') + if (firstCursor(q)) { + stage = 'copy' + park('copy-after-cursor') + } + if (sql.startsWith('update `snapshot_index_generation_v2` set `complete` = ?') && q.bindings[0] === true) + stage = 'complete' + if (sql.startsWith('delete from `snapshot_global_edges`')) stage = 'retire' + if ( + sql.startsWith('update `snapshot_index_generation_v2` set `retiretable` = ?') && + q.bindings[0] === retiredTables.length + ) + stage = 'retire-complete' + if (sql === 'commit;') committed() + if (sql.startsWith('insert into `knex_migrations`') && q.bindings.includes(migration)) park('publication-committed') + }) + try { + await source.migrate('generation loss', 'synthetic-generation-loss') + throw new Error('Requested generation boundary was not reached: ' + phase) + } finally { + await source.destroy() + } +} +async function terminateAt(filename, phase, directory) { + const marker = path.join(directory, 'boundary.json') + const handle = spawn(process.execPath, [__filename, 'child'], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + let stderr = '' + handle.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-65536) + }) + const exited = new Promise((resolve, reject) => { + handle.once('exit', (code, signal) => resolve({ code, signal })) + handle.once('error', reject) + }) + const timer = setTimeout(() => handle.kill('SIGKILL'), 15000) + try { + handle.send({ filename, phase, marker }) + const result = await exited + assert.equal(result.signal, 'SIGKILL', stderr) + assert.equal(JSON.parse(await fs.readFile(marker, 'utf8')).phase, phase) + return result + } finally { + clearTimeout(timer) + if (handle.exitCode === null && handle.signalCode === null) { + handle.kill('SIGKILL') + await exited + } + } +} +async function qualifySQLiteGenerationProcessLoss() { + const results = [] + await runInSeries(phases, async phase => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts569-sqlite-generation-')) + const filename = path.join(directory, 'wallet.sqlite') + let source + try { + const seeded = await seed(filename) + const result = await terminateAt(filename, phase, directory) + source = provider(filename) + const k = source.knex + assert.deepEqual(await k('transactions').orderBy('transactionId'), seeded.rows) + const early = [ + 'install-lock', + 'install-first-table', + 'install-partial-observers', + 'install-before-commit' + ].includes(phase) + assert.equal(await k.schema.hasTable(metadata), !early) + if (early) await oracle(k) + else { + assert.equal(await readGenerationIndexState(k), phase === 'publication-committed' ? 'v2' : false) + const after = (await k(progress).where('stream', 0).first()).afterId + if (['install-committed', 'copy-before-cursor', 'copy-after-cursor', 'copy-before-commit'].includes(phase)) + assert.equal(after, 0) + else if (phase === 'copy-committed') assert.equal(after, 512) + else assert.equal(after, 1200) + } + if (phase === 'retirement-before-commit') assert.equal((await k('snapshot_global_edges')).length, 600) + if (phase === 'retirement-page-committed') assert.equal((await k('snapshot_global_edges')).length, 344) + const published = await k('knex_migrations').where('name', migration) + assert.equal(published.length, phase === 'publication-committed' ? 1 : 0) + // Independent mutations below the committed source cursor must be represented after recovery. + await k('transactions').where('transactionId', 2).update({ userId: seeded.otherId }) + await k('transactions').where('transactionId', 4).delete() + await k('transactions').insert({ ...seeded.rows[0], transactionId: 3, reference: 'new-low-key' }) + // This exact owned child has exited; no other migrator can own the fixture lock. + await k.migrate.forceFreeMigrationsLock() + assert.equal(await source.migrate('generation loss', 'synthetic-generation-loss'), migration) + assert.equal(await readGenerationIndexState(k), 'v2') + const mapping = new Map([ + ['snapshot_global_edges', names.edges], + ['snapshot_global_keys', names.keys], + ['snapshot_global_guards', names.guards] + ]) + const rebuilt = new Proxy(k, { + apply(target, self, args) { + if (mapping.has(args[0])) args[0] = mapping.get(args[0]) + return Reflect.apply(target, self, args) + } + }) + await oracle(rebuilt) + const actual = await k(names.profile).where('snapshotTableId', 0).orderBy('snapshotRowId') + const expected = await k('transactions') + .select({ snapshotRowId: 'transactionId', snapshotUserId: 'userId' }) + .orderBy('transactionId') + assert.deepEqual( + actual.map(({ snapshotRowId, snapshotUserId }) => ({ snapshotRowId, snapshotUserId })), + expected + ) + for (const table of retiredTables) assert.equal(await k.schema.hasTable(table), false) + assert.equal((await k('knex_migrations').where('name', migration)).length, 1) + assert.equal((await k.raw('PRAGMA foreign_keys'))[0].foreign_keys, 1) + results.push({ + phase, + signal: result.signal, + sourceRowsRetained: true, + completeJournalAfterRecovery: true, + independentLowKeyWrites: true + }) + } finally { + if (source) await source.destroy() + await fs.rm(directory, { recursive: true, force: true }) + } + }) + return results +} +if (process.argv[2] === 'child') { + assert.equal(typeof process.send, 'function', 'Native fixture child requires its parent') + process.once('message', ({ filename, phase, marker }) => { + child(filename, phase, marker).catch(error => { + console.error(error) + process.exitCode = 1 + process.disconnect() + }) + }) +} +module.exports = { qualifySQLiteGenerationProcessLoss } diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotHistoricalMigrations.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotHistoricalMigrations.ts new file mode 100644 index 000000000..a6faee332 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotHistoricalMigrations.ts @@ -0,0 +1,21 @@ +import { StorageKnex } from '../../src/storage/StorageKnex' +import { KnexMigrations, SNAPSHOT_SQLITE_INDEX_MIGRATION } from '../../src/storage/schema/KnexMigrations' + +/** Deliberately build the historical schema whose migration a fixture exercises. */ +export async function migrateBeforeSqliteGeneration( + source: StorageKnex, + storageName: string, + storageIdentityKey: string +): Promise { + const migrationSource = new KnexMigrations(source.chain, storageName, storageIdentityKey, 1024) + delete migrationSource.migrations[SNAPSHOT_SQLITE_INDEX_MIGRATION] + const config = { migrationSource, disableTransactions: false } + const sqlite = String(source.knex.client.config.client).includes('sqlite') + if (sqlite) await source.knex.raw('PRAGMA foreign_keys=OFF') + try { + await source.knex.migrate.latest(config) + return await source.knex.migrate.currentVersion(config) + } finally { + if (sqlite) await source.knex.raw('PRAGMA foreign_keys=ON') + } +} diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteFixtures.ts new file mode 100644 index 000000000..aa4d0d364 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteFixtures.ts @@ -0,0 +1,157 @@ +import { knex, type Knex } from 'knex' +import { installMembershipDraft } from './snapshotSqliteMaintenanceFixture' +import { addSnapshotProfileIndexes } from '../../src/storage/schema/snapshotProfileIndexMigration' +import { addSnapshotRelationIndexes } from '../../src/storage/schema/snapshotRelationIndexMigration' +import { addSnapshotCertificateIndexes } from '../../src/storage/schema/snapshotCertificateIndexMigration' +import { addSnapshotGlobalIndexes } from '../../src/storage/schema/snapshotGlobalIndexMigration' +import { expectRelationMembership } from './snapshotRelationFixtures' +import { expectCertificateMembership } from './snapshotCertificateFixtures' +import { expectGlobalMembership } from './snapshotGlobalFixtures' + +export const profiles = [ + ['transactions', 'transactionId'], + ['outputs', 'outputId'], + ['certificates', 'certificateId'], + ['tx_labels', 'txLabelId'], + ['output_baskets', 'basketId'], + ['output_tags', 'outputTagId'], + ['commissions', 'commissionId'], + ['sync_states', 'syncStateId'] +] +export const numeric = [...profiles, ['proven_txs', 'provenTxId'], ['proven_tx_reqs', 'provenTxReqId']] +export const tables = [...numeric.map(([table]) => table), 'tx_labels_map', 'output_tags_map', 'certificate_fields'] + +export async function fixture(collation: string, recursive: boolean, corrected = true, filename = ':memory:') { + const k = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const schema = [ + 'proven_txs(provenTxId INTEGER PRIMARY KEY,txid VARCHAR(64) NOT NULL UNIQUE)', + 'proven_tx_reqs(provenTxReqId INTEGER PRIMARY KEY,txid VARCHAR(64) NOT NULL UNIQUE,provenTxId INTEGER)', + 'transactions(transactionId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,reference VARCHAR(64) NOT NULL UNIQUE,txid VARCHAR(64),provenTxId INTEGER)', + 'outputs(outputId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,transactionId INTEGER NOT NULL,vout INTEGER NOT NULL,UNIQUE(transactionId,vout,userId))', + 'certificates(certificateId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,type VARCHAR(100) NOT NULL,certifier VARCHAR(130) NOT NULL,serialNumber VARCHAR(100) NOT NULL,UNIQUE(userId,type,certifier,serialNumber))', + 'tx_labels(txLabelId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,label VARCHAR(300) NOT NULL,UNIQUE(label,userId))', + 'output_tags(outputTagId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,tag VARCHAR(150) NOT NULL,UNIQUE(tag,userId))', + 'output_baskets(basketId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,name VARCHAR(300) NOT NULL,UNIQUE(name,userId))', + 'commissions(commissionId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,transactionId INTEGER NOT NULL UNIQUE)', + 'sync_states(syncStateId INTEGER PRIMARY KEY,userId INTEGER NOT NULL,refNum VARCHAR(100) NOT NULL UNIQUE)', + 'tx_labels_map(txLabelId INTEGER NOT NULL,transactionId INTEGER NOT NULL,UNIQUE(txLabelId,transactionId))', + 'output_tags_map(outputTagId INTEGER NOT NULL,outputId INTEGER NOT NULL,UNIQUE(outputTagId,outputId))', + 'certificate_fields(userId INTEGER NOT NULL,fieldName VARCHAR(100) NOT NULL,certificateId INTEGER NOT NULL,fieldValue VARCHAR(255) NOT NULL,UNIQUE(fieldName,certificateId))' + ] + try { + await k.raw('PRAGMA recursive_triggers=' + Number(recursive)) + if (filename !== ':memory:') await k.raw('PRAGMA journal_mode=WAL') + for (const sql of schema) + await k.raw('CREATE TABLE ' + sql.replaceAll(/VARCHAR\(\d+\)/g, type => type + ' COLLATE ' + collation)) + await k.schema.alterTable('transactions', table => { + void table.index('txid') + }) + await k.schema.alterTable('tx_labels_map', table => { + void table.index('transactionId') + }) + await k.schema.alterTable('output_tags_map', table => { + void table.index('outputId') + }) + await addSnapshotProfileIndexes(k) + await addSnapshotRelationIndexes(k) + await addSnapshotCertificateIndexes(k) + await addSnapshotGlobalIndexes(k) + await k.schema.createTable('knex_migrations', table => { + table.increments('id') + table.string('name').unique().notNullable() + table.integer('batch').notNullable() + table.timestamp('migration_time').notNullable() + }) + await k('knex_migrations').insert( + [ + '2026-10-01-003 add snapshot profile key indexes', + '2026-10-01-004 add snapshot relation key indexes', + '2026-10-01-005 add snapshot certificate field key indexes', + '2026-10-01-006 add snapshot global reference indexes' + ].map(name => ({ name, batch: 1, migration_time: new Date('2026-01-01') })) + ) + if (corrected) await installMembershipDraft(k) + return k + } catch (error) { + await k.destroy() + throw error + } +} + +export async function exact(k: Knex) { + const expected: Array<{ + snapshotTableId: number + snapshotUserId: number + snapshotRowId: number + }> = [] + for (const [id, [table, key]] of profiles.entries()) + for (const row of await k(table)) + expected.push({ snapshotTableId: id, snapshotUserId: row.userId, snapshotRowId: row[key] }) + expected.sort( + (a, b) => + a.snapshotTableId - b.snapshotTableId || a.snapshotUserId - b.snapshotUserId || a.snapshotRowId - b.snapshotRowId + ) + expect(await k('snapshot_profile_keys').orderBy(['snapshotTableId', 'snapshotUserId', 'snapshotRowId'])).toEqual( + expected + ) + await expectRelationMembership(k) + await expectCertificateMembership(k) + await expectGlobalMembership(k) +} + +export function value(table: string, id: number, other: number, userId: number): Record { + const key = numeric.find(([name]) => name === table)?.[1] + const row = key ? { [key]: id, ...(profiles.some(([name]) => name === table) ? { userId } : {}) } : {} + switch (table) { + case 'transactions': + return { ...row, reference: 'r' + other, txid: 't' + other, provenTxId: other } + case 'outputs': + return { ...row, transactionId: other, vout: id % 2 } + case 'certificates': + return { ...row, type: 'a', certifier: 'b', serialNumber: 's' + other } + case 'tx_labels': + return { ...row, label: 'l' + other } + case 'output_tags': + return { ...row, tag: 'g' + other } + case 'output_baskets': + return { ...row, name: 'b' + other } + case 'commissions': + return { ...row, transactionId: other } + case 'sync_states': + return { ...row, refNum: 'r' + other } + case 'proven_txs': + return { ...row, txid: 't' + other } + case 'proven_tx_reqs': + return { ...row, txid: 't' + other, provenTxId: other } + case 'tx_labels_map': + return { txLabelId: id, transactionId: other } + case 'output_tags_map': + return { outputTagId: id, outputId: other } + case 'certificate_fields': + return { + userId, + fieldName: ['a', 'A', 'a ', 'é', 'A\0B', '名字'][id - 1], + certificateId: other, + fieldValue: 'p' + } + default: + throw new Error('Unknown fixture table') + } +} +export function keyOf(table: string, id: number, other: number) { + const key = numeric.find(([name]) => name === table)?.[1] + if (key) return { [key]: id } + const result = value(table, id, other, 1) + delete result.userId + delete result.fieldValue + return result +} +export async function replace(k: Knex, table: string, row: Record) { + const query = k(table).insert(row).toSQL() + await k.raw(query.sql.replace(/^insert/i, 'INSERT OR REPLACE'), query.bindings) +} diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteIdentityFixture.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteIdentityFixture.ts new file mode 100644 index 000000000..604f96832 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteIdentityFixture.ts @@ -0,0 +1,26 @@ +import type { Knex } from 'knex' +import type { IdentityDefinition } from '../../src/storage/schema/snapshotSqliteIdentity' +import { WERR_INVALID_OPERATION } from '../../src/sdk/WERR_errors' + +/** Bound the background copy by the numeric source key; writes share its transaction. */ +export async function copyIdentityPage( + k: Knex, + identity: IdentityDefinition, + after: number, + count = 256 +): Promise { + if (!k.isTransaction) throw new WERR_INVALID_OPERATION('Identity bootstrap requires a transaction') + if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(count) || count < 1 || count > 256) + throw new WERR_INVALID_OPERATION('Invalid identity bootstrap position') + const columns = identity.columns.map(column => column.name) + const rows = await k(identity.source.table) + .select(columns) + .where(identity.source.key, '>', after) + .orderBy(identity.source.key) + .limit(count) + for (const row of rows) { + const primary = [identity.source.key, ...(identity.source.owner ? [identity.source.owner] : [])] + await k(identity.table).insert(row).onConflict(primary).merge() + } + return rows.length === count ? Number(rows.at(-1)[identity.source.key]) : undefined +} diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteMaintenanceFixture.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteMaintenanceFixture.ts new file mode 100644 index 000000000..b6c58842f --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteMaintenanceFixture.ts @@ -0,0 +1,24 @@ +import type { Knex } from 'knex' +import { readIdentity, identityDDL, type IdentityDefinition } from '../../src/storage/schema/snapshotSqliteIdentity' +import { numeric, relations, membershipTriggers } from '../../src/storage/schema/snapshotSqliteMembership' + +export async function installMembershipDraft(k: Knex): Promise { + const definitions: IdentityDefinition[] = [] + for (const source of numeric) definitions.push(await readIdentity(k, source)) + await k.transaction(async trx => { + for (const definition of definitions) for (const ddl of identityDDL(definition)) await trx.raw(ddl) + const sources = [ + ...numeric.map(source => source.table), + ...relations.map(relation => relation.table), + 'certificate_fields' + ] + const old: Array<{ name: string }> = await trx('sqlite_master') + .where('type', 'trigger') + .whereIn('tbl_name', sources) + .select('name') + for (const trigger of old) + if (/^snapshot_(profile|relation|certificate|global)_/.test(trigger.name)) + await trx.raw('DROP TRIGGER ??', [trigger.name]) + for (const sql of membershipTriggers(definitions)) await trx.raw(sql) + }) +} diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index 4077bcc44..4d21035ae 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -27,6 +27,14 @@ const plans = new Map([ ['src/storage/schema/snapshotGlobalIndexSqlite.ts', 'global-sqlite'], ['src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'global-bootstrap'], ['src/storage/schema/snapshotGlobalIndexTriggers.ts', 'global-triggers'], + ['src/storage/schema/snapshotSqliteIdentity.ts', 'sqlite-identity'], + ['src/storage/schema/snapshotSqliteMembership.ts', 'sqlite-identity'], + ['src/storage/schema/snapshotSqliteIndexGeneration.ts', 'sqlite-generation'], + ['src/storage/schema/snapshotSqliteIndexBootstrap.ts', 'sqlite-bootstrap'], + ['src/storage/schema/snapshotSqliteIndexState.ts', 'sqlite-bootstrap'], + ['src/storage/schema/snapshotSqliteIndexRetirement.ts', 'sqlite-retirement'], + ['src/storage/schema/snapshotSqliteLegacyOwnership.ts', 'sqlite-generation'], + ['src/storage/schema/snapshotSqliteIndexMigration.ts', 'sqlite-retirement'], ['src/storage/StorageKnex.ts', 'storage'], ['src/storage/StorageProvider.ts', 'storage'] ]) diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 4138db90d..8a9dd0622 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -271,6 +271,10 @@ for (const [id, expected, fallback] of [ 'global-sqlite', 'global-bootstrap', 'global-triggers', + 'sqlite-identity', + 'sqlite-generation', + 'sqlite-bootstrap', + 'sqlite-retirement', 'storage' ], 'lifecycle' diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index dc0e02daf..3150fa0f5 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -202,7 +202,13 @@ test('additional package-relative fixture inputs select their target without rep assert.deepEqual(canonical['wallet-retained-snapshot'].additionalInputs, [ 'test/utils/snapshotRelationFixtures.ts', 'test/utils/snapshotCertificateFixtures.ts', - 'test/utils/snapshotGlobalFixtures.ts' + 'test/utils/snapshotGlobalFixtures.ts', + 'test/utils/snapshotHistoricalMigrations.ts', + 'test/utils/snapshotSqliteFixtures.ts', + 'test/utils/snapshotSqliteIdentityFixture.ts', + 'test/utils/snapshotSqliteMaintenanceFixture.ts', + 'test/storage/snapshotHistoricalMigrations.cjs', + 'test/storage/snapshotSqliteGenerationCrash.cjs' ]) assert.deepEqual( selectAffectedMutationTargets(canonical, [ diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index 5c00f0eaf..a520ea209 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -247,3 +247,15 @@ Incomplete journaled state refuses new views. This advances indexed profile selection only: the remaining relationship/global-table work, immutable commit-order high-water positions, complete tombstone propagation, nonblocking IndexedDB and the rest of the implementation program remain required. + +The SQLite conflict-maintenance follow-up adds an explicit forward migration for +all four auxiliary index families. Metadata-bound BEFORE/AFTER witnesses preserve +replacement and nested-owner semantics; twelve bounded streams rebuild a fresh +generation under independent writes, and bounded physical-row retirement removes +only obsolete auxiliary tables. Reader generation and journal state are selected +inside the retained view. Historical-migration fixtures remain explicit, while +current ordinary/archive readers and actual registered-migrator process-loss +fixtures exercise adoption and recovery. Explicit full-data deletion remains +supported; normal downgrade refuses without removing standard records. This +advances indexed selection/recovery within S2 and V2. It does not complete +committed-change continuity, large-wallet performance or the remaining program. From 18748e2f4f5cf4305b83baaae8aa1cc7865bf5d9 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 22:01:19 -0700 Subject: [PATCH 082/127] test(wallet): verify bounded SQLite retirement completion and rollback --- ...SnapshotSqliteGenerationRetirement.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRetirement.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRetirement.test.ts index 8ed98a548..2318cd432 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRetirement.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRetirement.test.ts @@ -151,3 +151,95 @@ test.each(['{', 'null', '[null]', '[{"type":"table","name":"foreign","tbl_name": } } ) + +test.each([-1, 257, 0.5, NaN, '256', undefined])( + 'an invalid driver deletion count %s rolls back actual retired rows and progress', + async invalid => { + const k = await fixture('BINARY', false, false) + const client = Object.getPrototypeOf(k.client) as { processResponse: (...args: unknown[]) => unknown } + const original = client.processResponse + let response: ReturnType | undefined + try { + for (let start = 0; start < 600; start += 100) + await k('transactions').insert( + Array.from({ length: 100 }, (_, i) => value('transactions', start + i + 1, start + i + 1, 1)) + ) + const plan = await installGeneration(k) + let complete = false + for (let page = 0; page < 100 && !complete; page++) complete = (await copyGenerationPage(k, plan)).complete + expect(complete).toBe(true) + const before = await k('snapshot_global_edges').orderBy('transactionId') + expect(before).toHaveLength(600) + let injected = 0 + response = jest.spyOn(client, 'processResponse').mockImplementation(function (this: unknown, ...args: unknown[]) { + const result = original.apply(this, args) + const query = args[0] + if ( + typeof query === 'object' && + query !== null && + 'sql' in query && + typeof query.sql === 'string' && + query.sql.startsWith('delete from `snapshot_global_edges`') + ) { + expect(result).toBe(256) + injected++ + return invalid + } + return result + }) + await expect(retireGenerationPage(k, plan)).rejects.toThrow('Invalid retirement row count') + expect(injected).toBe(1) + response.mockRestore() + response = undefined + expect(await k('snapshot_global_edges').orderBy('transactionId')).toEqual(before) + expect(await k(metadata).first('retireTable')).toEqual({ retireTable: 0 }) + expect(await k('transactions')).toHaveLength(600) + } finally { + response?.mockRestore() + await k.destroy() + } + } +) + +test('the final nonempty legacy table reports completion only after its empty-table drop commits', async () => { + const k = await fixture('BINARY', false, false) + try { + await k('certificates').insert(value('certificates', 1, 1, 1)) + for (let start = 0; start < 257; start += 100) + await k('certificate_fields').insert( + Array.from({ length: Math.min(100, 257 - start) }, (_, i) => ({ + userId: 1, + certificateId: 1, + fieldName: 'field-' + (start + i), + fieldValue: 'v' + })) + ) + const plan = await installGeneration(k) + let complete = false + for (let n = 0; n < 100 && !complete; n++) complete = (await copyGenerationPage(k, plan)).complete + expect(complete).toBe(true) + const table = retiredTables.at(-1)! + let finalPage: Awaited> | undefined + for (let n = 0; n < 20; n++) { + const page = await retireGenerationPage(k, plan) + expect(page.complete).toBe(false) + if (page.table === table) { + finalPage = page + break + } + } + expect(finalPage).toEqual({ complete: false, removed: 256, table }) + expect(await k(table)).toHaveLength(1) + expect(await k(metadata).first('retireTable')).toEqual({ retireTable: retiredTables.length - 1 }) + expect(await retireGenerationPage(k, plan)).toEqual({ complete: false, removed: 1, table }) + expect(await k.schema.hasTable(table)).toBe(true) + expect(await k(table)).toEqual([]) + expect(await retireGenerationPage(k, plan)).toEqual({ complete: true, removed: 0, table }) + expect(await k(metadata).first('retireTable')).toEqual({ retireTable: retiredTables.length }) + for (const retired of retiredTables) expect(await k.schema.hasTable(retired)).toBe(false) + expect(await k('certificate_fields')).toHaveLength(257) + expect(await k(names.certificate)).toHaveLength(257) + } finally { + await k.destroy() + } +}) From 0152aab65949557a54009479295e4a71ece54fc8 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Thu, 1 Oct 2026 23:55:47 -0700 Subject: [PATCH 083/127] fix(wallet): bind SQLite retirement ownership to object type --- docs/guides/wallet-sync-reliability.md | 7 +- docs/reference/ci-performance.md | 11 + docs/reference/package-api-migrations.md | 2 +- docs/reference/test-quality-governance.md | 12 + governance/package-release-notes.json | 2 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 4 +- packages/wallet/wallet-toolbox/README.md | 4 +- .../schema/snapshotSqliteLegacyOwnership.ts | 14 +- .../snapshot/SnapshotSqliteGeneration.test.ts | 211 +++++++++++++++++- scripts/mutation-partitions.mjs | 2 +- scripts/mutation-partitions.test.mjs | 1 + 11 files changed, 257 insertions(+), 13 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index d20951279..950f1d265 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -436,7 +436,12 @@ MySQL continues to use its existing maintenance. Installation validates the complete prior migrations, source definitions and owned auxiliary objects, then atomically installs a fresh v2 generation and -invalidates the previous progress states. Twelve source streams copy at most +invalidates the previous progress states. Ownership binds both object type and +name: a view sharing an owned trigger name remains a foreign reader. Foreign +views or triggers that depend on legacy auxiliary data refuse installation +before those tables can be retired. Orphan generation objects and unsupported +composite comparison or source-order definitions also refuse adoption. +Twelve source streams copy at most 256 rows per transaction with typed durable positions. Independent writes, including inserts below the saved cursor, remain observed throughout the copy. Each completed page yields before the next transaction. Once the copy completes, diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index 001a41c9e..641e6b161 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -354,6 +354,17 @@ the full pinned inventory/configuration on the final source before adoption. Registry, runtime, assertions, workers, deadlines and thresholds remain owned and unchanged by the partition facility. +The SQLite conflict-repair extension retains all eight canonical helper files in +five whole-file groups: identity, membership, generation/legacy ownership, +bootstrap/state and retirement/migration. Identity and membership originally +shared a part. On source `18748e2f4`, that part passed all 759 baseline tests and +instrumented 465 mutants, but reached its 90-minute limit without a complete +report; three native worker crashes remain in the failed-run evidence. Separating +the two complete files preserves every canonical source, test and fixture, four +workers, runner reuse of eight, the 90-minute per-part limit, and each critical +score/uncovered/invalid gate. Complete fresh qualification is required; splitting +does not establish a runtime improvement or turn the timed-out run into a pass. + ### Remote reader execution allowance The complete `wallet-snapshot-remote-reader` campaign at wallet source diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 81d1ba647..b0124c57f 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -514,7 +514,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. +- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. - Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 884816885..f83711c1b 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -258,6 +258,18 @@ its traversal tests assert fixture bounds and cursor progress so broken paging fails promptly. Partitioning and test changes require fresh complete evidence; the cancelled run does not qualify these targets. +The eight SQLite conflict-repair helpers remain complete canonical retained +sources. Identity and membership each execute as a whole-file part; generation +shares its part with legacy ownership, bootstrap with state, and retirement with +the migration entry point. The identity/membership split follows a local +90-minute timeout on source `18748e2f4`, with 465 instrumented mutants and all 759 +baseline tests passing but no complete mutation result. Preserve that failure +and its three worker crash warnings. All parts keep the full current canonical +tests and fixtures, four workers, reuse eight, the 90-minute limit, and their +independent critical score and zero-uncovered/invalid gates. Aggregation requires +the complete disjoint source union and fresh matching provenance; no target, +property budget, dependency-selection rule or acceptance threshold changes. + PR CI downloads each selected target's complete partition artifacts before canonical verification. The orchestration regression derives the required downloads from the canonical target registry and execution map, preventing a diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 512be1683..74f6e8571 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,7 +210,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged.", + "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired.", "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance." }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 5c9d5f134..892e93679 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -9,7 +9,9 @@ attention to changes that materially alter behavior or extend functionality. - Repair SQLite replacement maintenance with a new auxiliary generation, durable displaced-owner witnesses, bounded resumable source copying and physical-row retirement. Preserve source tables, profile/reference semantics and pinned - ordinary/archive views. Apply the explicit forward migration; normal downgrade + ordinary/archive views. Bind legacy trigger ownership to its object type so + foreign views sharing a trigger name cannot bypass retirement refusal. Apply + the explicit forward migration; normal downgrade refuses, while explicit full-data deletion remains supported. Complete native, mutation, larger-wallet and hosted acceptance remain required. diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index f5a1d4ce5..92e3cec9a 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -129,7 +129,9 @@ indexes and cursor/portable bytes remain unchanged. See the The additive SQLite conflict-repair migration installs metadata-bound witnesses, rebuilds all four index families in bounded resumable pages, and retires obsolete auxiliary rows without changing standard wallet data. Reader adoption follows -journal publication inside the pinned view. Preserve partial state for recovery; +journal publication inside the pinned view. Legacy ownership checks distinguish +triggers from views even when their names coincide; foreign readers of auxiliary +data refuse migration. Preserve partial state for recovery; ordinary downgrade refuses, while explicit `dropAllData()` retains its destructive contract. See the [SQLite generation migration contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#sqlite-conflict-safe-index-generation-unpublished-candidate). The complete sync/streaming/restore program remains in progress on #569. diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts index d82b0a2c1..ce66c947c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts @@ -62,15 +62,17 @@ export async function validateLegacy(k: Knex, config?: Knex.MigratorConfig): Pro ) const names = legacyTriggerNames() const foreign: Array<{ + type: string name: string + tbl_name: string sql: string - }> = await k('sqlite_master').whereIn('type', ['trigger', 'view']).select('name', 'sql') + }> = await k('sqlite_master').whereIn('type', ['trigger', 'view']).select('type', 'name', 'tbl_name', 'sql') for (const object of foreign) - if (!names.has(object.name) && retiredTables.some(table => new RegExp('\\b' + table + '\\b', 'i').test(object.sql))) + if ( + !(object.type === 'trigger' && names.has(object.name)) && + (retiredTables.includes(object.tbl_name) || + retiredTables.some(table => new RegExp('\\b' + table + '\\b', 'i').test(object.sql))) + ) throw new WERR_INVALID_OPERATION('Unowned object references legacy auxiliary data') - const aux = await legacySchema(k) - for (const row of aux) - if (row.type === 'trigger' && !names.has(row.name)) - throw new WERR_INVALID_OPERATION('Unknown legacy auxiliary trigger') return names } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGeneration.test.ts index f78791b38..a6bcb67e6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGeneration.test.ts @@ -3,9 +3,19 @@ import { dropGenerationForDataDeletion } from '../schema/snapshotSqliteIndexMigr import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' import type { Knex } from 'knex' import { fixture, exact, value, replace, tables } from '../../../test/utils/snapshotSqliteFixtures' -import { installGeneration, names, metadata, progress, oldProgress } from '../schema/snapshotSqliteIndexGeneration' +import { + installGeneration, + readPlan, + validateInstalled, + names, + metadata, + progress, + oldProgress +} from '../schema/snapshotSqliteIndexGeneration' import { copyGenerationPage } from '../schema/snapshotSqliteIndexBootstrap' import { legacyNames } from '../schema/snapshotSqliteMembership' +import { retiredTables } from '../schema/snapshotSqliteLegacyOwnership' +import { knex } from 'knex' function generation(k: Knex): Knex { const mapping = new Map(Object.entries(legacyNames).map(([key, value]) => [value, names[key as keyof typeof names]])) @@ -93,6 +103,205 @@ test('page rollback keeps its cursor and memberships together; resumed copies ar } }) +test.each([ + ['tx_labels_map', 'txLabelId INTEGER,transactionId INTEGER,UNIQUE(transactionId,txLabelId)'], + ['tx_labels_map', 'txLabelId INTEGER,transactionId INTEGER,UNIQUE(txLabelId)'], + ['tx_labels_map', 'txLabelId INTEGER,transactionId INTEGER,UNIQUE(txLabelId DESC,transactionId)'], + ['tx_labels_map', 'txLabelId INTEGER,transactionId INTEGER,UNIQUE(txLabelId COLLATE NOCASE,transactionId)'], + ['tx_labels_map', 'txLabelId INTEGER,transactionId INTEGER,UNIQUE(txLabelId,transactionId COLLATE RTRIM)'], + [ + 'certificate_fields', + 'userId INTEGER,fieldName VARCHAR(100),certificateId INTEGER,fieldValue TEXT,UNIQUE(fieldName,certificateId COLLATE NOCASE)' + ] +])('unsupported native composite comparison refuses before writing: %s / %s', async (table, columns) => { + const k = await fixture('BINARY', false, false) + try { + await k.schema.dropTable(table) + await k.raw(`CREATE TABLE ?? (${columns})`, [table]) + const before = await k('sqlite_master').orderBy(['type', 'name']) + await expect(installGeneration(k)).rejects.toThrow('Unsupported composite identity comparison') + expect(await k('sqlite_master').orderBy(['type', 'name'])).toEqual(before) + } finally { + await k.destroy() + } +}) + +test('an index whose comparison differs from the source order refuses a sorting scan', async () => { + const k = await fixture('BINARY', false, false) + try { + await k.schema.dropTable('certificate_fields') + await k.raw( + 'CREATE TABLE certificate_fields(userId INTEGER,fieldName VARCHAR(100),certificateId INTEGER,fieldValue TEXT,UNIQUE(fieldName COLLATE NOCASE,certificateId))' + ) + const query = 'SELECT fieldName,certificateId FROM certificate_fields ORDER BY fieldName,certificateId LIMIT 1' + expect( + (await k.raw('EXPLAIN QUERY PLAN ' + query)).some((step: { detail: string }) => + step.detail.includes('TEMP B-TREE') + ) + ).toBe(true) + await expect(installGeneration(k)).rejects.toThrow('Composite source order mismatch') + expect(await k.schema.hasTable(metadata)).toBe(false) + } finally { + await k.destroy() + } +}) + +test('the SQLite generation refuses a different database client before issuing queries', async () => { + const k = knex({ client: 'mysql2' }) + const query = jest.fn() + k.on('query', query) + try { + await expect(readPlan(k)).rejects.toThrow('SQLite rebuild requires SQLite') + expect(query).not.toHaveBeenCalled() + } finally { + await k.destroy() + } +}) + +test('validation binds the observed source schema and rejects malformed generated definitions', async () => { + const k = await fixture('BINARY', false, false) + try { + const plan = await installGeneration(k) + await k.raw('CREATE INDEX extra_source_index ON tx_labels(userId)') + await expect(validateInstalled(k, plan)).rejects.toThrow('Rebuild source schema changed') + await k.raw('DROP INDEX extra_source_index') + await expect(validateInstalled(k, { ...plan, ddl: ['SELECT 1'] })).rejects.toThrow( + 'Invalid generated schema definition' + ) + await expect(validateInstalled(k, plan)).resolves.toBeUndefined() + } finally { + await k.destroy() + } +}) + +test.each(['absent', 'extra', 'wrong id', 'invalid complete', 'binary legacy'])( + 'generation metadata must be one correctly typed source-bound row: %s', + async kind => { + const k = await fixture('BINARY', false, false) + try { + const plan = await installGeneration(k) + if (kind === 'absent') await k(metadata).delete() + if (kind === 'extra') await k(metadata).insert({ ...(await k(metadata).first()), id: 1 }) + if (kind === 'wrong id') await k(metadata).update({ id: 1 }) + if (kind === 'invalid complete') await k(metadata).update({ complete: 2 }) + if (kind === 'binary legacy') await k.raw('UPDATE ?? SET legacy = ?', [metadata, Buffer.from([0])]) + await expect(validateInstalled(k, plan)).rejects.toThrow('Rebuild source binding mismatch') + } finally { + await k.destroy() + } + } +) + +test.each(oldProgress)('an incomplete prior index refuses replacement: %s', async table => { + const k = await fixture('BINARY', false, false) + try { + await k(table).update({ complete: false }) + await k('knex_migrations') + .where('name', 'like', `% snapshot ${table.split('_')[1]} %`) + .delete() + const before = await k('sqlite_master').orderBy(['type', 'name']) + await expect(installGeneration(k)).rejects.toThrow('Prior snapshot migrations must be complete') + expect(await k('sqlite_master').orderBy(['type', 'name'])).toEqual(before) + } finally { + await k.destroy() + } +}) + +test.each(retiredTables)('foreign views of %s prevent retiring their data', async table => { + const k = await fixture('BINARY', false, false) + try { + await k.raw('CREATE VIEW foreign_snapshot_reader AS SELECT * FROM ??', [table.toUpperCase()]) + await expect(installGeneration(k)).rejects.toThrow('Unowned object references legacy auxiliary data') + expect(await k.schema.hasTable(metadata)).toBe(false) + expect(await k.schema.hasTable(table)).toBe(true) + } finally { + await k.destroy() + } +}) + +test('a view using a known trigger name is still an unowned reader of legacy data', async () => { + const k = await fixture('BINARY', false, false) + try { + await k.raw('CREATE VIEW snapshot_profile_0_insert AS SELECT * FROM snapshot_profile_keys') + expect(await k('sqlite_master').where('name', 'snapshot_profile_0_insert').orderBy('type').select('type')).toEqual([ + { type: 'trigger' }, + { type: 'view' } + ]) + await expect(installGeneration(k)).rejects.toThrow('Unowned object references legacy auxiliary data') + expect(await k.schema.hasTable(metadata)).toBe(false) + } finally { + await k.destroy() + } +}) + +test('a foreign auxiliary trigger prevents retirement even when its body reads no legacy data', async () => { + const k = await fixture('BINARY', false, false) + try { + await k.raw('CREATE TRIGGER application_auxiliary_hook AFTER INSERT ON snapshot_profile_keys BEGIN SELECT 1; END') + await expect(installGeneration(k)).rejects.toThrow('Unowned object references legacy auxiliary data') + expect(await k.schema.hasTable(metadata)).toBe(false) + } finally { + await k.destroy() + } +}) + +test('orphan generation objects refuse adoption without replacing their contents', async () => { + const k = await fixture('BINARY', false, false) + try { + await k.schema.createTable(names.profile, table => { + table.text('foreignValue') + }) + await k(names.profile).insert({ foreignValue: 'preserve' }) + await expect(installGeneration(k)).rejects.toThrow('Orphan rebuild schema refuses adoption') + expect(await k(names.profile)).toEqual([{ foreignValue: 'preserve' }]) + expect(await k.schema.hasTable(metadata)).toBe(false) + } finally { + await k.destroy() + } +}) + +test('reserved source trigger names refuse while independent application triggers are retained', async () => { + const k = await fixture('BINARY', false, false) + try { + await k.raw('CREATE TRIGGER snapshot_profile_unowned AFTER INSERT ON tx_labels BEGIN SELECT 1; END') + await expect(installGeneration(k)).rejects.toThrow('Unknown legacy source trigger') + await k.raw('DROP TRIGGER snapshot_profile_unowned') + await k.raw('CREATE TABLE application_events(id INTEGER)') + await k.raw( + 'CREATE TRIGGER app_snapshot_profile_notice AFTER INSERT ON tx_labels BEGIN INSERT INTO application_events VALUES(NEW.txLabelId); END' + ) + await installGeneration(k) + await k('tx_labels').insert(value('tx_labels', 1, 1, 1)) + expect(await k('application_events')).toEqual([{ id: 1 }]) + } finally { + await k.destroy() + } +}) + +test('the installed generation owns every required secondary range index', async () => { + const k = await fixture('BINARY', false, false) + try { + await installGeneration(k) + const required = { + snapshot_relation_right_v2: ['snapshotTableId', 'snapshotUserId', 'snapshotRightId', 'snapshotLeftId'], + snapshot_relation_map_v2: ['snapshotTableId', 'snapshotLeftId', 'snapshotRightId', 'snapshotUserId'], + snapshot_certificate_parent_v2: ['snapshotCertificateId', 'snapshotUserId', 'snapshotFieldName'], + snapshot_certificate_lookup_v2: ['snapshotFieldName', 'snapshotCertificateId', 'snapshotUserId'], + snapshot_global_page_v2: ['tableId', 'userId', 'present', 'rowId'], + snapshot_global_target_v2: ['tableId', 'rowId', 'userId'], + snapshot_global_request_v2: ['requestId', 'transactionId'] + } + for (const [name, columns] of Object.entries(required)) { + const parts: Array<{ key: number; name: string; desc: number }> = await k.raw('PRAGMA index_xinfo(??)', [name]) + expect(parts.filter(part => part.key === 1).map(part => ({ name: part.name, desc: part.desc }))).toEqual( + columns.map(name => ({ name, desc: 0 })) + ) + } + } finally { + await k.destroy() + } +}) + test.each(['tx_labels_map', 'output_tags_map', 'certificate_fields'])( 'unsupported additional unique constraint on %s refuses atomically', async table => { diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index 4d21035ae..a0145ebb3 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -28,7 +28,7 @@ const plans = new Map([ ['src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'global-bootstrap'], ['src/storage/schema/snapshotGlobalIndexTriggers.ts', 'global-triggers'], ['src/storage/schema/snapshotSqliteIdentity.ts', 'sqlite-identity'], - ['src/storage/schema/snapshotSqliteMembership.ts', 'sqlite-identity'], + ['src/storage/schema/snapshotSqliteMembership.ts', 'sqlite-membership'], ['src/storage/schema/snapshotSqliteIndexGeneration.ts', 'sqlite-generation'], ['src/storage/schema/snapshotSqliteIndexBootstrap.ts', 'sqlite-bootstrap'], ['src/storage/schema/snapshotSqliteIndexState.ts', 'sqlite-bootstrap'], diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 8a9dd0622..1dcaf8ba1 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -272,6 +272,7 @@ for (const [id, expected, fallback] of [ 'global-bootstrap', 'global-triggers', 'sqlite-identity', + 'sqlite-membership', 'sqlite-generation', 'sqlite-bootstrap', 'sqlite-retirement', From ec09fed6d8edaecf2c7dbbe5d00b1a7af79cb7de Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 02:33:41 -0700 Subject: [PATCH 084/127] fix(wallet): bind SQLite publication to the configured journal schema --- docs/guides/wallet-sync-reliability.md | 4 +- docs/reference/package-api-migrations.md | 74 ++--- governance/package-release-notes.json | 4 +- .../schema/snapshotSqliteIndexState.ts | 8 +- .../snapshot/KnexWalletReadSnapshot.test.ts | 20 ++ .../snapshot/SnapshotSqliteBootstrap.test.ts | 254 ++++++++++++++++++ .../SnapshotSqliteGenerationRegistry.test.ts | 12 + .../snapshot/SnapshotSqliteIdentity.test.ts | 183 +++++++++++++ 8 files changed, 516 insertions(+), 43 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteBootstrap.test.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 950f1d265..5faaea04d 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -454,7 +454,9 @@ schema/progress after interruption. Recover a stale Knex lock only after proving the migrator stopped. The real migrator publishes its journal after copy and retirement; partial state uses source-query fallback in new readers. Complete journal/progress state selects v2 indexes inside the same retained view as all -pages. An already-pinned WAL reader retains its original view across retirement. +pages. Publication lookup uses the explicitly configured SQLite migration-journal +schema; an identically named journal in another attached database cannot publish +this generation. An already-pinned WAL reader retains its original view across retirement. Older binaries cannot adopt invalidated progress: do not downgrade their snapshot implementation against this schema. Ordinary migration rollback refuses without deleting source rows; use a separately designed forward migration. The explicit diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index b0124c57f..ae0b2e91e 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. -- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. +- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. +- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 74f6e8571..76daa6164 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired.", - "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance." + "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records.", + "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexState.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexState.ts index de22d2b04..a3c879337 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexState.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexState.ts @@ -17,10 +17,12 @@ export async function readGenerationIndexState( throw new WERR_INVALID_OPERATION('Invalid generation progress') const state = await k(metadata).where('id', 0).first('complete') const journal = config?.tableName ?? 'knex_migrations' - const journalSchema = k.schema - if (config?.schemaName !== undefined) void journalSchema.withSchema(config.schemaName) + // SQLite's Knex hasTable query ignores withSchema; inspect the selected + // database catalog explicitly before reading its migration journal. + const catalog = k('sqlite_master').where({ type: 'table', name: journal }).first('name') + if (config?.schemaName !== undefined) void catalog.withSchema(config.schemaName) let published = false - if (await journalSchema.hasTable(journal)) { + if ((await catalog) !== undefined) { const query = k(journal).where('name', migration) if (config?.schemaName !== undefined) void query.withSchema(config.schemaName) published = (await query.first('name')) !== undefined diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts index 1f56b1342..37166365a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.test.ts @@ -1,3 +1,4 @@ +import { WERR_NOT_IMPLEMENTED } from '../../sdk/WERR_errors' import { migrateBeforeSqliteGeneration } from '../../../test/utils/snapshotHistoricalMigrations' import { removeSnapshotCertificateIndexes, @@ -685,3 +686,22 @@ test('a page query failure is observed through read and cleanup before the provi expect((await fresh.readPage('txLabels')).rows).toHaveLength(1) await fresh.close() }) + +test('a Knex provider that disables packed snapshots refuses before opening or querying', async () => { + const k = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + try { + jest.spyOn(source, 'supportsWalletReadSnapshot').mockReturnValue(false) + const available = jest + .spyOn(source, 'makeAvailable') + .mockRejectedValue(new Error('unexpected database acquisition')) + const open = jest.spyOn(source, 'openReadSnapshot').mockRejectedValue(new Error('unexpected snapshot acquisition')) + const request = source.openWalletReadSnapshot('02' + '11'.repeat(32)) + await expect(request).rejects.toBeInstanceOf(WERR_NOT_IMPLEMENTED) + await expect(request).rejects.toThrow('Wallet read snapshot pages are not supported by this provider') + expect(available).not.toHaveBeenCalled() + expect(open).not.toHaveBeenCalled() + } finally { + await source.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteBootstrap.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteBootstrap.test.ts new file mode 100644 index 000000000..da1d4c920 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteBootstrap.test.ts @@ -0,0 +1,254 @@ +import type { Knex } from 'knex' +import { fixture, value } from '../../../test/utils/snapshotSqliteFixtures' +import { installGeneration, names, progress, metadata } from '../schema/snapshotSqliteIndexGeneration' +import { copyGenerationPage, valid, type Position } from '../schema/snapshotSqliteIndexBootstrap' +import { readGenerationIndexState, migration } from '../schema/snapshotSqliteIndexState' + +const initial: Position = { stream: 0, afterId: 0, afterSecond: 0, afterText: '', complete: 0 } +test.each([ + { stream: -1 }, + { stream: 12 }, + { stream: 0.5 }, + { stream: NaN }, + { stream: Infinity }, + { afterId: -1 }, + { afterId: 0.5 }, + { afterId: 9007199254740992 }, + { afterSecond: 1 }, + { stream: 8, afterId: 1 }, + { stream: 9, afterSecond: 1 }, + { stream: 10, afterText: 'a' }, + { stream: 10, afterId: 1, afterText: 100 }, + { stream: 10, afterId: 1, afterText: 'a'.repeat(101) }, + { complete: 2 } +])('persisted position refuses unsupported state %j', patch => { + expect(valid({ ...initial, ...patch } as Position)).toBe(false) +}) +test('position accepts the last stream, safe ID boundary, exact 100-codepoint 400-byte field and both relation components', () => { + for (const state of [ + { ...initial, stream: 11, afterId: Number.MAX_SAFE_INTEGER, complete: 1 }, + { ...initial, stream: 10, afterId: 1, afterText: '😀'.repeat(100) }, + { ...initial, stream: 8, afterId: 1, afterSecond: 2 }, + { ...initial, stream: 9, afterId: 2, afterSecond: 1 } + ]) + expect(valid(state)).toBe(true) +}) + +async function prepareStream(k: Knex, stream: number) { + const plan = await installGeneration(k) + await k(progress).update({ complete: 1 }) + await k(progress).where('stream', stream).update({ complete: 0 }) + return plan +} +const invalidSources: Array<{ table: string; stream: number; patch: Record; message: string }> = [ + ...[0, -1, 9007199254740992].map(transactionId => ({ + table: 'transactions', + stream: 0, + patch: { transactionId }, + message: 'Invalid profile source identity' + })), + ...[0, 1.5, 'not an integer'].map(userId => ({ + table: 'transactions', + stream: 0, + patch: { userId }, + message: 'Invalid profile source identity' + })), + { table: 'tx_labels_map', stream: 8, patch: { txLabelId: 0 }, message: 'Invalid relation source identity' }, + { table: 'tx_labels_map', stream: 8, patch: { transactionId: 0 }, message: 'Invalid relation source identity' }, + { table: 'output_tags_map', stream: 9, patch: { outputTagId: -1 }, message: 'Invalid relation source identity' }, + { table: 'output_tags_map', stream: 9, patch: { outputId: 1.5 }, message: 'Invalid relation source identity' }, + { + table: 'certificate_fields', + stream: 10, + patch: { certificateId: 0 }, + message: 'Invalid certificate source identity' + }, + { table: 'certificate_fields', stream: 10, patch: { userId: 0 }, message: 'Invalid certificate source identity' }, + { + table: 'certificate_fields', + stream: 10, + patch: { fieldName: 'a'.repeat(101) }, + message: 'Invalid certificate source identity' + }, + { + table: 'certificate_fields', + stream: 10, + patch: { fieldName: '😀'.repeat(101) }, + message: 'Invalid certificate source identity' + }, + { + table: 'certificate_fields', + stream: 10, + patch: { fieldName: Buffer.from('field') }, + message: 'Invalid certificate source identity' + }, + { table: 'transactions', stream: 11, patch: { transactionId: 0 }, message: 'Invalid global source identity' }, + { table: 'transactions', stream: 11, patch: { userId: 0 }, message: 'Invalid global source identity' }, + { table: 'transactions', stream: 11, patch: { provenTxId: 0 }, message: 'Invalid global source identity' }, + { table: 'transactions', stream: 11, patch: { txid: 'a'.repeat(65) }, message: 'Invalid global source identity' }, + { table: 'transactions', stream: 11, patch: { txid: Buffer.from('txid') }, message: 'Invalid global source identity' } +] +test.each(invalidSources)( + 'historical $table identity refuses atomically at stream $stream: $patch', + async ({ table, stream, patch, message }) => { + const k = await fixture('BINARY', false, false) + try { + await k(table).insert({ ...value(table, 1, 1, 1), ...patch }) + const original = await k(table), + plan = await prepareStream(k, stream), + before = await k(progress).orderBy('stream') + await expect(copyGenerationPage(k, plan)).rejects.toThrow(message) + expect(await k(progress).orderBy('stream')).toEqual(before) + expect(await k(table)).toEqual(original) + for (const target of [names.profile, names.relation, names.certificate, names.edges]) + expect(await k(target)).toEqual([]) + } finally { + await k.destroy() + } + } +) +test.each([ + ['tx_labels_map', 'tx_labels', 8, 'Invalid relation source owner'], + ['output_tags_map', 'outputs', 9, 'Invalid relation source owner'], + ['certificate_fields', 'certificates', 10, 'Invalid certificate source owner'] +] as const)( + 'historical %s refuses invalid parent ownership and rolls back partial work', + async (table, parent, stream, message) => { + const k = await fixture('BINARY', false, false) + try { + await k(parent).insert(value(parent, 1, 1, 0)) + await k(table).insert(value(table, 1, 1, 1)) + const plan = await prepareStream(k, stream) + await expect(copyGenerationPage(k, plan)).rejects.toThrow(message) + expect(await k(progress).where('stream', stream).first()).toMatchObject({ ...initial, stream }) + expect(await k(stream === 10 ? names.certificate : names.relation)).toEqual([]) + } finally { + await k.destroy() + } + } +) +test.each([{ provenTxReqId: 0 }, { provenTxId: 0 }])( + 'historical global request identity %j refuses before adding edges', + async patch => { + const k = await fixture('BINARY', false, false) + try { + await k('transactions').insert({ ...value('transactions', 1, 1, 1), provenTxId: null }) + await k('proven_tx_reqs').insert({ ...value('proven_tx_reqs', 1, 1, 1), ...patch }) + const plan = await prepareStream(k, 11) + await expect(copyGenerationPage(k, plan)).rejects.toThrow('Invalid global request identity') + expect(await k(names.edges)).toEqual([]) + } finally { + await k.destroy() + } + } +) +test('asymmetric relation identities resolve each owner and retain the exact final tuple', async () => { + const k = await fixture('BINARY', false, false) + try { + await k('tx_labels').insert(value('tx_labels', 2, 2, 3)) + await k('transactions').insert(value('transactions', 7, 7, 5)) + await k('tx_labels_map').insert([ + { txLabelId: 2, transactionId: 8 }, + { txLabelId: 2, transactionId: 7 }, + { txLabelId: 1, transactionId: 7 } + ]) + const plan = await prepareStream(k, 8), + page = await copyGenerationPage(k, plan) + expect(page.copiedThrough).toEqual({ ...initial, stream: 8, afterId: 2, afterSecond: 8, complete: 1 }) + expect(await k(names.relation).orderBy(['snapshotLeftId', 'snapshotRightId', 'snapshotUserId'])).toEqual([ + { snapshotTableId: 0, snapshotUserId: 5, snapshotLeftId: 1, snapshotRightId: 7, snapshotMembership: 2 }, + { snapshotTableId: 0, snapshotUserId: 3, snapshotLeftId: 2, snapshotRightId: 7, snapshotMembership: 1 }, + { snapshotTableId: 0, snapshotUserId: 5, snapshotLeftId: 2, snapshotRightId: 7, snapshotMembership: 2 }, + { snapshotTableId: 0, snapshotUserId: 3, snapshotLeftId: 2, snapshotRightId: 8, snapshotMembership: 1 } + ]) + } finally { + await k.destroy() + } +}) +test('orphan fields keep their own profile and the maximum field spelling survives the cursor', async () => { + const k = await fixture('BINARY', false, false) + try { + await k('certificate_fields').insert([ + { ...value('certificate_fields', 1, 7, 3), fieldName: '😀'.repeat(100) }, + { ...value('certificate_fields', 1, 2, 5), fieldName: 'a' } + ]) + const plan = await prepareStream(k, 10), + page = await copyGenerationPage(k, plan) + expect(page.copiedThrough).toEqual({ ...initial, stream: 10, afterId: 7, afterText: '😀'.repeat(100), complete: 1 }) + expect(await k(names.certificate).orderBy('snapshotFieldName')).toEqual([ + { snapshotUserId: 5, snapshotCertificateId: 2, snapshotFieldName: 'a', snapshotMembership: 1 }, + { snapshotUserId: 3, snapshotCertificateId: 7, snapshotFieldName: '😀'.repeat(100), snapshotMembership: 1 } + ]) + } finally { + await k.destroy() + } +}) +test('unproven requests and null transaction IDs add only established global edges', async () => { + const k = await fixture('BINARY', false, false) + try { + await k('transactions').insert([ + { ...value('transactions', 1, 1, 1), txid: null, provenTxId: null }, + { ...value('transactions', 2, 2, 2), provenTxId: null } + ]) + await k('proven_tx_reqs').insert({ ...value('proven_tx_reqs', 5, 2, 1), provenTxId: null }) + const plan = await prepareStream(k, 11) + await copyGenerationPage(k, plan) + expect(await k(names.edges)).toEqual([{ transactionId: 2, requestId: 5, tableId: 0, rowId: 5, userId: 2 }]) + expect(await k(names.guards)).toEqual([]) + } finally { + await k.destroy() + } +}) +test.each(['missing', 'extra', 'wrong stream', 'second cursor', 'binary text'])( + 'corrupt %s progress refuses both copying and publication', + async kind => { + const k = await fixture('BINARY', false, false) + try { + const plan = await installGeneration(k) + if (kind === 'missing') await k(progress).where('stream', 11).delete() + if (kind === 'extra') await k(progress).insert({ ...initial, stream: 12 }) + if (kind === 'wrong stream') await k(progress).where('stream', 0).update({ stream: 12 }) + if (kind === 'second cursor') await k(progress).where('stream', 0).update({ afterSecond: 1 }) + if (kind === 'binary text') + await k(progress) + .where('stream', 10) + .update({ afterId: 1, afterText: Buffer.from('a') }) + const before = await k(progress).orderBy('stream') + await expect(copyGenerationPage(k, plan)).rejects.toThrow('Invalid generation progress') + await expect(readGenerationIndexState(k)).rejects.toThrow('Invalid generation progress') + expect(await k(progress).orderBy('stream')).toEqual(before) + } finally { + await k.destroy() + } + } +) +test('publication requires its configured schema journal and all completed streams', async () => { + const k = await fixture('BINARY', false, false) + try { + const plan = await installGeneration(k) + await k(progress).update({ complete: 1 }) + await copyGenerationPage(k, plan) + await k.raw("ATTACH DATABASE ':memory:' AS release_registry") + await k.schema.withSchema('release_registry').createTable('published', table => { + table.string('name').notNullable() + }) + const config = { schemaName: 'release_registry', tableName: 'published' } + await k.schema.createTable('published', table => { + table.string('name').notNullable() + }) + await k('published').insert({ name: migration }) + expect(await readGenerationIndexState(k, config)).toBe(false) + await k('published').withSchema('release_registry').insert({ name: migration }) + expect(await readGenerationIndexState(k, config)).toBe('v2') + await k(metadata).update({ complete: 0 }) + await expect(readGenerationIndexState(k, config)).rejects.toThrow('Published generation is incomplete') + await k(metadata).update({ complete: 1 }) + await k(progress).where('stream', 4).update({ complete: 0 }) + await expect(readGenerationIndexState(k, config)).rejects.toThrow('Completed generation has unfinished streams') + await k(progress).update({ complete: 1 }) + await k.schema.withSchema('release_registry').dropTable('published') + expect(await readGenerationIndexState(k, config)).toBe(false) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts index afb7a0841..925e57e95 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts @@ -114,3 +114,15 @@ test('MySQL retains its existing migration and deletion behavior without SQLite await refuseGenerationDowngrade(database) await dropGenerationForDataDeletion(database) }) + +test('empty migration adapters without a dialect label do not issue SQLite cleanup commands', async () => { + const currentVersion = jest.fn(async (_config: Knex.MigratorConfig) => 'none') + const down = jest.fn(), + raw = jest.fn() + const database = { client: { config: {} }, migrate: { currentVersion, down }, raw } as unknown as Knex + await StorageKnex.prototype.dropAllData.call({ knex: database } as StorageKnex) + expect(currentVersion).toHaveBeenCalledTimes(1) + expect(currentVersion.mock.calls[0][0].disableTransactions).toBe(false) + expect(down).not.toHaveBeenCalled() + expect(raw).not.toHaveBeenCalled() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteIdentity.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteIdentity.test.ts index 873408509..fc9b28883 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteIdentity.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteIdentity.test.ts @@ -310,3 +310,186 @@ test.each([false, true])('nested ownership changes retain every displaced owner, } } }) + +function openIdentitySchema() { + return knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) +} + +test.each([ + 'id INTEGER,owner INTEGER,label VARCHAR(100)', + 'id INTEGER,owner INTEGER,label VARCHAR(100),PRIMARY KEY(id,owner)', + 'id INTEGER,owner INTEGER PRIMARY KEY,label VARCHAR(100)', + 'id BIGINT PRIMARY KEY,owner INTEGER,label VARCHAR(100)' +])('unsupported native numeric identity refuses before creating witnesses: %s', async columns => { + const k = openIdentitySchema() + try { + await k.raw(`CREATE TABLE candidate_identity(${columns})`) + await expect(readIdentity(k, { table: 'candidate_identity', key: 'id', owner: 'owner' })).rejects.toThrow( + 'Unsupported numeric identity' + ) + expect(await k.schema.hasTable('snapshot_identity_candidate_identity')).toBe(false) + } finally { + await k.destroy() + } +}) + +test('partial unique constraints cannot establish complete conflict witnesses', async () => { + const k = openIdentitySchema() + try { + await k.raw('CREATE TABLE candidate_identity(id INTEGER PRIMARY KEY,owner INTEGER,label VARCHAR(100))') + await k.raw('CREATE UNIQUE INDEX conditional_identity ON candidate_identity(label) WHERE owner=1') + await expect(readIdentity(k, { table: 'candidate_identity', key: 'id', owner: 'owner' })).rejects.toThrow( + 'Unsupported partial unique identity' + ) + } finally { + await k.destroy() + } +}) + +test('mixed expression and column unique keys refuse unsupported comparison semantics', async () => { + const k = openIdentitySchema() + try { + await k.raw('CREATE TABLE candidate_identity(id INTEGER PRIMARY KEY,owner INTEGER,label VARCHAR(100))') + await k.raw('CREATE UNIQUE INDEX expression_identity ON candidate_identity(owner,LOWER(label))') + await expect(readIdentity(k, { table: 'candidate_identity', key: 'id', owner: 'owner' })).rejects.toThrow( + 'Unsupported unique identity comparison' + ) + } finally { + await k.destroy() + } +}) + +test.each([ + 'label TEXT', + 'label BLOB', + 'label REAL', + 'label VARCHAR(0)', + 'label UNSIGNED BIGINT', + 'label INTEGER UNSIGNED', + 'label VARCHAR(100) GENERATED ALWAYS AS (CAST(id AS TEXT)) VIRTUAL', + 'label VARCHAR(100) GENERATED ALWAYS AS (CAST(id AS TEXT)) STORED' +])('unsupported or generated unique column refuses witness adoption: %s', async column => { + const k = openIdentitySchema() + try { + await k.raw(`CREATE TABLE candidate_identity(id INTEGER PRIMARY KEY,owner INTEGER,${column},UNIQUE(label))`) + await expect(readIdentity(k, { table: 'candidate_identity', key: 'id', owner: 'owner' })).rejects.toThrow( + 'Unsupported identity column' + ) + } finally { + await k.destroy() + } +}) + +test('a missing ownership column refuses metadata adoption with its stable error', async () => { + const k = openIdentitySchema() + try { + await k.raw('CREATE TABLE candidate_identity(id INTEGER PRIMARY KEY,label VARCHAR(100) UNIQUE)') + await expect(readIdentity(k, { table: 'candidate_identity', key: 'id', owner: 'owner' })).rejects.toThrow( + 'Unsupported identity column' + ) + } finally { + await k.destroy() + } +}) + +test('witness primary components are explicitly non-nullable', async () => { + const k = openIdentitySchema() + try { + await k.raw('CREATE TABLE candidate_identity(id INTEGER PRIMARY KEY,owner INTEGER,label VARCHAR(100) UNIQUE)') + const identity = await readIdentity(k, { table: 'candidate_identity', key: 'id', owner: 'owner' }) + for (const sql of identityDDL(identity)) await k.raw(sql) + const columns: Array<{ name: string; notnull: number }> = await k.raw('PRAGMA table_info(??)', [identity.table]) + expect(columns.map(column => ({ name: column.name, notnull: column.notnull }))).toEqual([ + { name: 'id', notnull: 1 }, + { name: 'owner', notnull: 1 }, + { name: 'label', notnull: 0 } + ]) + await expect(k(identity.table).insert({ id: 1, owner: null, label: 'a' })).rejects.toThrow('NOT NULL') + await expect(k(identity.table).insert({ id: null, owner: 1, label: 'a' })).rejects.toThrow('NOT NULL') + } finally { + await k.destroy() + } +}) + +test('quoted native identifiers retain exact ownership and conflict comparison', async () => { + const k = openIdentitySchema() + const table = 'records"archive', + key = 'numeric"id', + owner = 'owner"id', + label = 'exact"label' + try { + await k.raw('CREATE TABLE ??(?? INTEGER PRIMARY KEY,?? INTEGER,?? VARCHAR(100) COLLATE NOCASE UNIQUE)', [ + table, + key, + owner, + label + ]) + const identity = await readIdentity(k, { table, key, owner }) + for (const sql of identityDDL(identity)) await k.raw(sql) + await k.raw(`CREATE TRIGGER quoted_before BEFORE INSERT ON ?? BEGIN ${observeIdentity(identity, false)} END`, [ + table + ]) + await k.raw(`CREATE TRIGGER quoted_after AFTER INSERT ON ?? BEGIN ${finishIdentity(identity, 'INSERT')} END`, [ + table + ]) + await k(table).insert({ [key]: 1, [owner]: 1, [label]: 'A' }) + const query = k(table) + .insert({ [key]: 2, [owner]: 2, [label]: 'a' }) + .toSQL() + await k.raw(query.sql.replace(/^insert/i, 'INSERT OR REPLACE'), query.bindings) + expect(await k(identity.table)).toEqual([{ [key]: 2, [owner]: 2, [label]: 'a' }]) + expect(await k(table)).toEqual(await k(identity.table)) + } finally { + await k.destroy() + } +}) + +test.each(['no key parts', 'unknown collation'])( + 'unexpected driver index metadata refuses before adoption: %s', + async kind => { + const k = openIdentitySchema() + const client = Object.getPrototypeOf(k.client) as { processResponse: (...args: unknown[]) => unknown } + const original = client.processResponse + let response: ReturnType | undefined + try { + await k.raw( + 'CREATE TABLE candidate_identity(id INTEGER PRIMARY KEY,owner INTEGER,label VARCHAR(100),UNIQUE(owner,label))' + ) + let injected = 0 + response = jest.spyOn(client, 'processResponse').mockImplementation(function (this: unknown, ...args: unknown[]) { + const result = original.apply(this, args) + const query = args[0] + if ( + typeof query === 'object' && + query !== null && + 'sql' in query && + typeof query.sql === 'string' && + query.sql.startsWith('PRAGMA index_xinfo(') + ) { + const parts = result as Array<{ name: string | null; key: number; coll: string }> + expect(parts.filter(part => part.key !== 0).map(part => part.name)).toEqual(['owner', 'label']) + injected++ + return kind === 'no key parts' + ? parts.filter(part => part.key === 0) + : parts.map(part => (part.name === 'label' ? { ...part, coll: 'UNSUPPORTED_COLLATION' } : part)) + } + return result + }) + await expect(readIdentity(k, { table: 'candidate_identity', key: 'id', owner: 'owner' })).rejects.toThrow( + 'Unsupported unique identity comparison' + ) + expect(injected).toBe(1) + response.mockRestore() + response = undefined + expect((await readIdentity(k, { table: 'candidate_identity', key: 'id', owner: 'owner' })).unique).toEqual([ + [ + { name: 'owner', collation: 'BINARY' }, + { name: 'label', collation: 'BINARY' } + ] + ]) + } finally { + response?.mockRestore() + await k.destroy() + } + } +) From 583d38526f8aa3074840470b21fa4d0e8ad5ed7d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 05:02:28 -0700 Subject: [PATCH 085/127] feat(wallet): add bounded source journal generation foundation --- .github/workflows/ci.yml | 7 +- .github/workflows/mutation-tests.yml | 2 +- docs/guides/wallet-sync-reliability.md | 27 + docs/reference/package-api-migrations.md | 74 +- governance/mutation-testing/policy.json | 10 + governance/mutation-testing/targets.mjs | 47 + governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 13 + packages/wallet/wallet-toolbox/CHANGELOG.md | 6 + packages/wallet/wallet-toolbox/README.md | 12 +- packages/wallet/wallet-toolbox/package.json | 6 +- .../snapshotCertificateIndexMigration.ts | 6 +- .../schema/snapshotProfileIndexMigration.ts | 10 +- .../schema/snapshotRelationIndexMigration.ts | 6 +- .../KnexWalletReadSnapshot.mysql.test.ts | 32 + .../journal/SnapshotJournal.property.test.ts | 944 ++ .../journal/SnapshotJournalBootstrap.test.ts | 322 + .../journal/SnapshotJournalBootstrap.ts | 347 + .../journal/SnapshotJournalHighWater.test.ts | 157 + .../journal/SnapshotJournalHighWater.ts | 48 + .../SnapshotJournalMysqlBootstrap.test.ts | 430 + .../journal/SnapshotJournalMysqlClock.test.ts | 36 + .../journal/SnapshotJournalMysqlClock.ts | 43 + .../SnapshotJournalMysqlGeneration.test.ts | 736 + .../journal/SnapshotJournalMysqlGeneration.ts | 493 + .../SnapshotJournalMysqlIntent.test.ts | 239 + .../journal/SnapshotJournalMysqlIntent.ts | 185 + .../journal/SnapshotJournalMysqlObservers.ts | 158 + .../SnapshotJournalMysqlSource.test.ts | 383 + .../journal/SnapshotJournalMysqlSource.ts | 253 + .../snapshot/journal/SnapshotJournalPage.ts | 199 + .../SnapshotJournalPrerequisites.test.ts | 52 + .../journal/SnapshotJournalRevision.ts | 29 + .../SnapshotJournalRevisionSql.test.ts | 64 + .../journal/SnapshotJournalRevisionSql.ts | 21 + .../SnapshotJournalSqliteClock.test.ts | 204 + .../journal/SnapshotJournalSqliteClock.ts | 46 + .../SnapshotJournalSqliteGeneration.test.ts | 300 + .../SnapshotJournalSqliteGeneration.ts | 235 + .../journal/SnapshotJournalSqliteObservers.ts | 137 + .../mysql-generation-ddl-fixture.json | 244 + .../mysql-generation-metadata-fixture.json | 13389 ++++++++++++++++ .../mysql-generation-state-fixture.json | 1 + .../mysql-intent-metadata-fixture.json | 112 + .../mysql-source-metadata-fixture.json | 5989 +++++++ .../test/storage/runSnapshotJournalMysql.cjs | 169 + .../test/storage/snapshotJournalMysql.cjs | 263 + .../snapshotJournalMysqlConnection.cjs | 25 + .../snapshotJournalMysqlServerCrash.cjs | 270 + .../storage/snapshotJournalNativeFixture.cjs | 229 + .../storage/snapshotJournalSqliteCrash.cjs | 166 + scripts/ci-orchestration.test.mjs | 2 +- scripts/mutation-partitions.mjs | 21 + scripts/mutation-partitions.test.mjs | 17 + scripts/mutation-testing.test.mjs | 48 +- scripts/test-governance.test.mjs | 4 +- 56 files changed, 27216 insertions(+), 56 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPage.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevision.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts create mode 100644 packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json create mode 100644 packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json create mode 100644 packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json create mode 100644 packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-intent-metadata-fixture.json create mode 100644 packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-source-metadata-fixture.json create mode 100644 packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlConnection.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalNativeFixture.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccfc4af89..c3e463bf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -503,7 +503,7 @@ jobs: # legitimately exceeds 20 minutes on a hosted runner. # Complete snapshot source-guard service qualification took 58m35s locally, # before hosted checkout/install. Preserve the 45-minute default elsewhere. - timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]'), matrix.target) && 90 || 45 }} + timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-journal","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: @@ -607,6 +607,11 @@ jobs: with: pattern: mutation-wallet-retained-snapshot-* path: .mutation-parts/wallet-retained-snapshot + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-snapshot-journal') + with: + pattern: mutation-wallet-snapshot-journal-* + path: .mutation-parts/wallet-snapshot-journal - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-snapshot-remote-http') with: diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 8c09bbc81..3953fb925 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -105,7 +105,7 @@ jobs: # Preserve the acknowledged wallet/overlays90-minute target union. # Complete snapshot source-guard service qualification took 58m35s locally, # before hosted checkout/install. Preserve the 45-minute default elsewhere. - timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]'), matrix.target) && 90 || 45 }} + timeout-minutes: ${{ contains(fromJSON('["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-journal","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]'), matrix.target) && 90 || 45 }} permissions: contents: read strategy: diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 5faaea04d..f4f8ee1c1 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -1037,3 +1037,30 @@ process, verify the reservation before loss and recover it afterward while preserving foreground writes. These fixtures do not establish deployed limits, distributed filesystems, PXC or physical mobile behavior. Reader advertisement remains disabled pending complete lifecycle and program qualification. + +## Internal journal foundation (unadvertised) + +The candidate includes internal SQL journal primitives for exact signed63 decimal +revisions, bounded composite metadata pages, thirteen-table observers, and +256-row bootstrap steps. Page limits charge every examined row. SQLite and MySQL +generations bind source schema and typed object ownership; MySQL persists an +atomic first intent and resumes each implicit-DDL step using the same epoch. +Unexpected objects or changed metadata refuse adoption. Standard source rows are +preserved by these helpers. Schema changes and concurrent migrators must be +excluded by the operator during installation or resumption. + +This foundation adds no registered migration, public capability or reader +advertisement. Its event-window invalidation is not a complete retention or +resource policy. Full quotas, durable capture/receipt/floor ownership, +generation-aware receiver/primary integration, and remaining remote/IndexedDB, +streaming and staged-import acceptance remain unfinished. Do not infer full +incremental continuity or completed issue #544 from these helpers. + +The wallet-snapshot-journal mutation target owns all thirteen complete source +modules in eleven execution parts. Every part retains the complete canonical +journal tests and fixtures, including one governed property entry for exact +revision/page and generated source-observer schedules. A minimum score of 90%, zero +uncovered/invalid mutants, 300 cases with seed 3242026, four workers, runner reuse 8 and 90-minute +bounds remain in force. Native ownership, client/server process-loss and broader +platform/performance evidence are separate required validation; source helpers +alone do not establish deployment, replication, PXC or power-loss readiness. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index ae0b2e91e..68c796072 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. -- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. +- Release note: Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. +- Migration: The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index a5fc2d4e8..92fd3e64f 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -67,6 +67,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-snapshot-journal", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts", + "risk": "critical", + "boundary": "Wallet exact journal revisions, bounded metadata pages and bootstrap, full-table observers, source binding and owned crash-resumable generation lifecycle", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "wallet-snapshot-sync", "manifest": "packages/wallet/wallet-toolbox/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 85518bacd..41a039f5e 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -485,6 +485,53 @@ export function buildMutationTargets(repositoryRoot) { } ) }, + 'wallet-snapshot-journal': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + additionalInputs: [ + 'test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json', + 'test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json', + 'test/fixtures/snapshotJournal/mysql-generation-state-fixture.json', + 'test/fixtures/snapshotJournal/mysql-intent-metadata-fixture.json', + 'test/fixtures/snapshotJournal/mysql-source-metadata-fixture.json', + 'test/utils/snapshotArchiveFixtures.ts', + 'test/utils/snapshotSqliteFixtures.ts', + 'test/utils/snapshotHistoricalMigrations.ts', + 'test/storage/snapshotJournalNativeFixture.cjs', + 'test/storage/snapshotJournalMysqlConnection.cjs', + 'test/storage/snapshotJournalMysql.cjs', + 'test/storage/snapshotJournalMysqlServerCrash.cjs', + 'test/storage/snapshotJournalSqliteCrash.cjs', + 'test/storage/runSnapshotJournalMysql.cjs', + 'test/storage/snapshotArchiveDocker.cjs' + ], + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts', + mutate: [ + 'src/storage/snapshot/journal/SnapshotJournalRevision.ts', + 'src/storage/snapshot/journal/SnapshotJournalRevisionSql.ts', + 'src/storage/snapshot/journal/SnapshotJournalPage.ts', + 'src/storage/snapshot/journal/SnapshotJournalSqliteClock.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlClock.ts', + 'src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts', + 'src/storage/snapshot/journal/SnapshotJournalBootstrap.ts', + 'src/storage/snapshot/journal/SnapshotJournalHighWater.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts', + 'src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts' + ], + ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/journal/*.test.ts'], { + maxTestRunnerReuse: 8, + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + }) + }, 'wallet-snapshot-archive': { packageDirectory: 'packages/wallet/wallet-toolbox', manifest: 'packages/wallet/wallet-toolbox/package.json', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 76daa6164..90f9d3e00 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records.", - "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding." + "summary": "Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records.", + "migration": "The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index b9c80b392..2cea2b1f3 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -532,6 +532,19 @@ "Numeric relationship membership equals the OR of current parent owners through generated map/parent rekeys, profile moves, tombstones, physical deletions, rollback and repeated migration; each ownership basis is retained independently." ] }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet exact journal revisions, bounded metadata pages and bootstrap, full-table observers, source binding and owned crash-resumable generation lifecycle", + "target": "Generated exact signed63 revision and metadata cursor schedules, plus thirteen-table insert/update/rekey/replacement/delete/rollback histories checked against independent source ownership oracles", + "invariants": [ + "Canonical decimal revisions compare exactly across the signed63 domain, including values above Number precision.", + "Bounded metadata pages preserve composite byte ordering, fixed upper bounds and examined-row limits without skipping tied revisions.", + "Generated source changes retain exact physical generations and ownership across all thirteen standard tables, including replacement, rollback and observer order variations.", + "Invalid source/schema or ownership evidence never silently adopts foreign DDL, and interrupted installation resumes only the persisted epoch and exact authorized next object." + ] + }, { "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts", "manifest": "packages/wallet/wallet-toolbox/package.json", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 892e93679..2c2d16379 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,12 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add internal, unadvertised SQL journal foundation: exact revisions, bounded + pages/bootstrap, full-table observers and source-bound generation ownership + with interrupted-installation recovery. No public capability, registered + migration, deployment or completed incremental sync is introduced. Full + quota/receipt/receiver/primary and portability acceptance remains pending. + - Repair SQLite replacement maintenance with a new auxiliary generation, durable displaced-owner witnesses, bounded resumable source copying and physical-row retirement. Preserve source tables, profile/reference semantics and pinned diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 92e3cec9a..a9f2d1619 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -51,7 +51,17 @@ readiness and the child proof have deadlines. Failure or cancellation drains owned work and attempts exact-owner cleanup before reporting its outcome; unproved cleanup fails qualification. Other wallet shards do not start MySQL. -Both native entry points include interrupted auxiliary profile and numeric +The candidate also contains internal SQL journal primitives for exact revisions, +bounded metadata pages and bootstrap, source-table observers, and recovery of an +interrupted journal installation. Run `pnpm test:snapshot-journal-crash` for the +SQLite process-loss fixture and `pnpm test:snapshot-journal-mysql` for the isolated +MySQL client/server-process recovery fixtures. These helpers do not register a +migration or advertise incremental synchronization. Full retention quotas, +capture receipts, receiver/primary integration and the remaining issue #544 +acceptance work are still required; see the +[journal foundation](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#internal-journal-foundation-unadvertised). + +Both native archive entry points include interrupted auxiliary profile and numeric relation migrations through the real migrator. The numeric relation fixture terminates seven migration boundaries, repairs the abandoned migration lock and compares recovered membership with an independent source-table oracle. The MySQL diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 1b75198d3..a2d176bee 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", @@ -74,7 +74,9 @@ "doc": "ts2md", "sync-versions": "node syncVersions.js", "test:snapshot-archive-crash": "pnpm build && node test/storage/snapshotArchiveCrash.cjs", - "test:snapshot-archive-mysql": "pnpm build && node test/storage/runSnapshotArchiveMysql.cjs" + "test:snapshot-archive-mysql": "pnpm build && node test/storage/runSnapshotArchiveMysql.cjs", + "test:snapshot-journal-crash": "pnpm build && node test/storage/snapshotJournalSqliteCrash.cjs", + "test:snapshot-journal-mysql": "pnpm build && node test/storage/runSnapshotJournalMysql.cjs" }, "bugs": { "url": "https://github.com/bsv-blockchain/ts-stack/issues" diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts index c6314ec85..5a9359c8e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotCertificateIndexMigration.ts @@ -135,7 +135,7 @@ function trigger( } } -function triggers(isMysql: boolean): { +export function snapshotCertificateIndexTriggers(isMysql: boolean): { observers: Trigger[] producers: Trigger[] } { @@ -547,7 +547,7 @@ export async function addSnapshotCertificateIndexes(k: Knex): Promise { ) const text = await sourceText(k) await ensureTables(k, text) - const { observers, producers } = triggers(mysql(k)) + const { observers, producers } = snapshotCertificateIndexTriggers(mysql(k)) await runInSeries([...observers, ...producers], async expected => { if (!(await validateTrigger(k, expected))) await k.raw(expected.sql) }) @@ -578,7 +578,7 @@ export async function removeSnapshotCertificateIndexes(k: Knex): Promise { const text = await sourceText(k) if (await k.schema.hasTable(KEYS)) await validateTable(k, KEYS, text) if (await k.schema.hasTable(PROGRESS)) await validateTable(k, PROGRESS, text) - const { observers, producers } = triggers(mysql(k)), + const { observers, producers } = snapshotCertificateIndexTriggers(mysql(k)), ordered = [...producers, ...observers] await runInSeries(ordered, async expected => { await validateTrigger(k, expected) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts index c9ae0a003..1e96cdc1e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotProfileIndexMigration.ts @@ -122,7 +122,13 @@ async function validateTable(k: Knex, table: string): Promise { type TriggerEvent = 'DELETE' | 'UPDATE' | 'INSERT' -function triggerDefinition(mysql: boolean, table: string, key: string, tableId: number, event: TriggerEvent) { +export function snapshotProfileTriggerDefinition( + mysql: boolean, + table: string, + key: string, + tableId: number, + event: TriggerEvent +) { const name = `snapshot_profile_${tableId}_${event.toLowerCase()}` const remove = `DELETE FROM ${KEYS} WHERE snapshotTableId = ${tableId} AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.${key};` const add = `INSERT INTO ${KEYS} (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (${tableId}, NEW.userId, NEW.${key});` @@ -180,7 +186,7 @@ async function installTrigger( create = true ): Promise { const mysql = isMySQL(k) - const { name, body, sql } = triggerDefinition(mysql, table, key, tableId, event) + const { name, body, sql } = snapshotProfileTriggerDefinition(mysql, table, key, tableId, event) const exists = mysql ? await mysqlTriggerExists(k, name, table, event, body) : await sqliteTriggerExists(k, name, sql) if (!exists && create) await k.raw(sql) } diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts index 363401d47..aa4b99387 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotRelationIndexMigration.ts @@ -107,7 +107,7 @@ function trigger( } } -function triggers(isMysql: boolean): { observers: Trigger[]; producers: Trigger[] } { +export function snapshotRelationIndexTriggers(isMysql: boolean): { observers: Trigger[]; producers: Trigger[] } { const observers: Trigger[] = [] const producers: Trigger[] = [] const different = (key: string): string => @@ -452,7 +452,7 @@ export async function addSnapshotRelationIndexes(k: Knex): Promise { if (mysql(k) && k.isTransaction) throw new WERR_INVALID_OPERATION('Snapshot relation migration requires independent DDL and bootstrap transactions') await ensureTables(k) - const { observers, producers } = triggers(mysql(k)) + const { observers, producers } = snapshotRelationIndexTriggers(mysql(k)) // Every graph-wide removal observer precedes every possible producer. await runInSeries([...observers, ...producers], async expected => { if (!(await validateTrigger(k, expected))) await k.raw(expected.sql) @@ -478,7 +478,7 @@ export async function removeSnapshotRelationIndexes(k: Knex): Promise { throw new WERR_INVALID_OPERATION('Snapshot relation migration requires independent DDL and bootstrap transactions') if (await k.schema.hasTable(KEYS)) await validateTable(k, KEYS) if (await k.schema.hasTable(PROGRESS)) await validateTable(k, PROGRESS) - const { observers, producers } = triggers(mysql(k)) + const { observers, producers } = snapshotRelationIndexTriggers(mysql(k)) const ordered = [...producers, ...observers] await runInSeries(ordered, async expected => { await validateTrigger(k, expected) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts index 0619dbc31..51898357a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.mysql.test.ts @@ -227,3 +227,35 @@ test('MySQL certificate pages preserve the collation-aware auxiliary key and ind expect(legacy.sql).not.toContain('snapshot_certificate_field_keys') expect(legacy.sql).toContain('or exists') }) + +test.each([ + ['provenTxReqs', 'proven_tx_reqs', 'provenTxReqId', 0], + ['provenTxs', 'proven_txs', 'provenTxId', 1] +] as const)( + 'MySQL %s pages retain the profile range plan and indexed source join', + async (table, name, key, tableId) => { + const k = knex({ client: 'mysql2' }) + try { + const query = walletSnapshotSourceQuery(k, table, 41, true, true, true, true) + .select(`${name}.*`) + .where('rowId', '>', 700) + .orderBy('rowId') + .limit(32) + .toSQL() + expect(query.sql).toContain(`/*+ JOIN_FIXED_ORDER() JOIN_INDEX(${name}) */`) + expect(query.sql).toContain( + 'from `snapshot_global_keys` FORCE INDEX (`snapshot_global_page`) cross join `' + name + '`' + ) + expect(query.sql).toContain('`rowId` = `' + name + '`.`' + key + '`') + expect(query.sql).toContain('`tableId` = ? and `userId` = ? and `present` = ? and `refs` > ? and `rowId` > ?') + expect(query.sql).toContain('order by `rowId` asc limit ?') + expect(query.bindings).toEqual([tableId, 41, 1, 0, 700, 32]) + const legacy = walletSnapshotSourceQuery(k, table, 41).toSQL() + expect(legacy.sql).not.toContain('snapshot_global_keys') + expect(legacy.sql).not.toContain('JOIN_FIXED_ORDER') + expect(legacy.sql).toContain('exists') + } finally { + await k.destroy() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts new file mode 100644 index 000000000..352890bf1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts @@ -0,0 +1,944 @@ +import fc from 'fast-check' +import { knex, type Knex } from 'knex' +import { + snapshotJournalRevision, + compareSnapshotJournalRevisions, + MAX_SNAPSHOT_JOURNAL_REVISION, + snapshotJournalRevision as rev +} from './SnapshotJournalRevision' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { + readSnapshotJournalMetadataPage, + snapshotJournalMetadataQuery, + type SnapshotJournalInterval, + type SnapshotJournalPosition +} from './SnapshotJournalPage' +import { + snapshotJournalSqliteObserverSql, + SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL +} from './SnapshotJournalSqliteObservers' +import { installSnapshotJournalSqliteClock } from './SnapshotJournalSqliteClock' +import { readGenerationIndexState } from '../../schema/snapshotSqliteIndexState' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' +import { + fixture, + value, + keyOf, + replace, + tables, + numeric, + profiles +} from '../../../../test/utils/snapshotSqliteFixtures' +import { installGeneration } from '../../schema/snapshotSqliteIndexGeneration' +import { copyGenerationPage } from '../../schema/snapshotSqliteIndexBootstrap' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +const propertyParameters = { + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + seed: Number.isSafeInteger(requestedSeed) ? requestedSeed : 3242026, + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +} +fc.configureGlobal(propertyParameters) + +describe('SnapshotJournalRevision', () => { + const boundaries = [ + '0', + '1', + '9', + '10', + '9007199254740991', + '9007199254740992', + '9007199254740993', + '9223372036854775806', + '9223372036854775807' + ] + test.each(boundaries)('preserves exact canonical revision %s', value => { + expect(snapshotJournalRevision(value)).toBe(value) + }) + + test.each([ + undefined, + null, + true, + 0, + 1, + 1n, + NaN, + Infinity, + [], + {}, + '', + '00', + '01', + '-1', + '+1', + ' 1', + '1 ', + '1\n', + '1.0', + '1e3', + '١', + '9', + '9223372036854775808', + '9999999999999999999', + '10000000000000000000', + '1'.repeat(10000) + ])('refuses a noncanonical or out-of-range revision %p', value => { + expect(() => snapshotJournalRevision(value)).toThrow(WERR_INVALID_OPERATION) + }) + + test('canonical ordering agrees with independent bigint arithmetic through the entire range', () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 9223372036854775807n }), + fc.bigInt({ min: 0n, max: 9223372036854775807n }), + (a, b) => { + const left = snapshotJournalRevision(a.toString()), + right = snapshotJournalRevision(b.toString()) + expect(compareSnapshotJournalRevisions(left, right)).toBe(a === b ? 0 : a < b ? -1 : 1) + expect(compareSnapshotJournalRevisions(left, left)).toBe(0) + expect(snapshotJournalRevision(JSON.parse(JSON.stringify(left)))).toBe(left) + } + ), + propertyParameters + ) + }) + + test('SQLite preserves exact decimal positions above 2^53 with typed range predicates', async () => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + try { + await k.raw( + "CREATE TABLE revisions(revision INTEGER NOT NULL PRIMARY KEY CHECK(typeof(revision)='integer' AND revision>=0))" + ) + for (const revision of boundaries) await k('revisions').insert({ revision }) + const exact = await k('revisions').select(k.raw('CAST(revision AS TEXT) AS revision')).orderBy('revision') + // ORDER BY the integer column explicitly; the text alias otherwise changes ordering. + const sorted = await k('revisions') + .select(k.raw('CAST(revisions.revision AS TEXT) AS exactRevision')) + .orderBy('revisions.revision') + expect(sorted.map(row => snapshotJournalRevision(row.exactRevision))).toEqual(boundaries) + expect(exact).toHaveLength(boundaries.length) + const page = await k('revisions') + .select(k.raw('CAST(revision AS TEXT) AS exactRevision')) + .whereRaw('revision > CAST(? AS INTEGER)', ['9007199254740992']) + .orderBy('revision') + .limit(2) + expect(page.map(row => snapshotJournalRevision(row.exactRevision))).toEqual([ + '9007199254740993', + '9223372036854775806' + ]) + const query = k('revisions') + .whereRaw('revision > CAST(? AS INTEGER)', ['9007199254740992']) + .orderBy('revision') + .limit(2) + .toSQL() + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) + expect(plan.some(row => row.detail.includes('SEARCH revisions USING INTEGER PRIMARY KEY'))).toBe(true) + expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false) + await expect( + k('revisions') + .where('revision', MAX_SNAPSHOT_JOURNAL_REVISION) + .update({ revision: k.raw('revision+1') }) + ).rejects.toThrow() + expect( + ( + await k('revisions') + .select(k.raw('CAST(revision AS TEXT) AS exactRevision')) + .whereRaw('revision = CAST(? AS INTEGER)', [MAX_SNAPSHOT_JOURNAL_REVISION]) + .first() + ).exactRevision + ).toBe(MAX_SNAPSHOT_JOURNAL_REVISION) + } finally { + await k.destroy() + } + }) +}) + +describe('SnapshotJournalPage', () => { + const base = 9007199254740992n + const interval: SnapshotJournalInterval = { + stream: 'scope', + tableId: 12, + userId: 1, + floor: rev('0'), + low: rev(String(base)), + high: rev(String(base + 10n)), + limit: 2 + } + + async function database(): Promise { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + await k.raw( + 'CREATE TABLE snapshot_journal_scope(tableId INTEGER,userId INTEGER,id1 INTEGER,id2 INTEGER,exactText TEXT COLLATE BINARY,revision INTEGER,present INTEGER,PRIMARY KEY(tableId,userId,id1,id2,exactText))' + ) + await k.raw( + 'CREATE INDEX snapshot_journal_scope_page ON snapshot_journal_scope(userId,tableId,revision,id1,id2,exactText)' + ) + await k.raw( + 'CREATE TABLE snapshot_journal_physical(tableId INTEGER,id1 INTEGER,id2 INTEGER,exactText TEXT COLLATE BINARY,revision INTEGER,generation INTEGER,present INTEGER,PRIMARY KEY(tableId,id1,id2,exactText))' + ) + await k.raw( + 'CREATE INDEX snapshot_journal_physical_page ON snapshot_journal_physical(tableId,revision,id1,id2,exactText)' + ) + return k + } + + const compare = (a: SnapshotJournalPosition, b: SnapshotJournalPosition) => + (BigInt(a.revision) < BigInt(b.revision) ? -1 : BigInt(a.revision) > BigInt(b.revision) ? 1 : 0) || + a.id1 - b.id1 || + a.id2 - b.id2 || + Buffer.compare(Buffer.from(a.exactText), Buffer.from(b.exactText)) + + test('real SQLite pages preserve 64-bit revisions, byte-exact ties and fixed upper bounds', async () => { + const k = await database() + try { + const rows = ['a', 'A', 'a ', 'a\0b', 'é', '中', '😀'].map((exactText, i) => ({ + tableId: 12, + userId: 1, + id1: i < 5 ? 1 : 2, + id2: 0, + exactText, + revision: rev(String(base + BigInt(i < 4 ? 1 : 2))), + present: i % 2 + })) + await k('snapshot_journal_scope').insert(rows) + await k('snapshot_journal_scope').insert({ ...rows[0], userId: 2 }) + await k('snapshot_journal_scope').insert({ ...rows[0], tableId: 3 }) + await k('snapshot_journal_scope').insert({ + ...rows[0], + id1: 999, + revision: String(base + 11n) + }) + const actual = [] + let after: SnapshotJournalPosition | undefined + for (let pages = 0; pages < 10; pages++) { + const page = await readSnapshotJournalMetadataPage(k, { ...interval, after }) + expect(page.examined).toBe(page.rows.length) + expect(page.examined).toBeLessThanOrEqual(2) + actual.push(...page.rows) + if (page.complete) break + expect(page.after).toBeDefined() + after = page.after + } + expect(actual).toEqual( + rows.sort(compare).map(({ tableId: _table, userId: _user, present, ...row }) => ({ + ...row, + present: present === 1 + })) + ) + const query = snapshotJournalMetadataQuery(k, { ...interval, after }).toSQL() + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + query.sql, query.bindings) + expect(plan.some(row => row.detail.includes('SEARCH j USING INDEX snapshot_journal_scope_page'))).toBe(true) + expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false) + } finally { + await k.destroy() + } + }) + + test('physical global pages charge every examined row and preserve exact insertion generations', async () => { + const k = await database() + try { + await k('snapshot_journal_physical').insert( + Array.from({ length: 5 }, (_, i) => ({ + tableId: 8, + id1: i + 1, + id2: 0, + exactText: '', + revision: String(base + BigInt(i) + 1n), + generation: String(base + 1n), + present: i % 2 + })) + ) + const page = await readSnapshotJournalMetadataPage(k, { + ...interval, + stream: 'physical', + tableId: 8 + }) + expect(page.examined).toBe(2) + expect(page.complete).toBe(false) + expect(page.rows.map(row => row.generation)).toEqual([String(base + 1n), String(base + 1n)]) + expect(page.rows.map(row => row.present)).toEqual([false, true]) + expect(await k('snapshot_journal_scope')).toEqual([]) + } finally { + await k.destroy() + } + }) + + test('late SQLite pages seek the complete composite cursor within a large tied revision', async () => { + const k = await database() + try { + for (let first = 1; first <= 10000; first += 200) { + await k('snapshot_journal_scope').insert( + Array.from({ length: 200 }, (_, i) => ({ + tableId: 12, + userId: 1, + id1: first + i, + id2: 0, + exactText: '', + revision: String(base + 1n), + present: 1 + })) + ) + } + const request = { + ...interval, + after: { revision: rev(String(base + 1n)), id1: 9900, id2: 0, exactText: '' } + } + expect((await readSnapshotJournalMetadataPage(k, request)).rows.map(row => row.id1)).toEqual([9901, 9902]) + const sql = snapshotJournalMetadataQuery(k, request).toSQL() + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + sql.sql, sql.bindings) + expect(plan.some(row => row.detail.includes('(revision,id1,id2,exactText)>(?,?,?,?)'))).toBe(true) + expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false) + const program: Array<{ opcode: string; p4: string | null }> = await k.raw('EXPLAIN ' + sql.sql, sql.bindings) + expect(program.some(step => ['SeekGE', 'SeekGT'].includes(step.opcode) && step.p4 === '6')).toBe(true) + } finally { + await k.destroy() + } + }) + + test('quiescent intervals issue no journal query, and stale floors refuse before I/O', async () => { + const k = await database() + const seen = jest.fn() + k.on('query', seen) + try { + expect(await readSnapshotJournalMetadataPage(k, { ...interval, high: interval.low })).toEqual({ + rows: [], + examined: 0, + complete: true + }) + await expect(readSnapshotJournalMetadataPage(k, { ...interval, floor: interval.high })).rejects.toThrow( + 'Invalid snapshot journal interval or metadata' + ) + expect(seen).not.toHaveBeenCalled() + } finally { + await k.destroy() + } + }) + + test.each([ + { limit: 0 }, + { limit: 257 }, + { limit: 1.5 }, + { limit: NaN }, + { userId: 0 }, + { userId: Number.MAX_SAFE_INTEGER + 1 }, + { tableId: 13 }, + { tableId: -1 }, + { stream: 'physical', tableId: 12 }, + { stream: 'unknown' }, + { high: rev('0') }, + { low: '01' }, + { high: '9223372036854775808' }, + { after: { revision: interval.low, id1: 1, id2: 0, exactText: '' } }, + { after: { revision: rev(String(base + 11n)), id1: 1, id2: 0, exactText: '' } }, + { after: { revision: interval.high, id1: 0, id2: 0, exactText: '' } }, + { after: { revision: interval.high, id1: 1, id2: -1, exactText: '' } }, + { after: { revision: interval.high, id1: 1, id2: 0, exactText: '😀'.repeat(101) } }, + { after: { revision: interval.high, id1: 1, id2: 0, exactText: '\ud800' } } + ])('malformed request refuses before SQL: %p', async change => { + const k = await database() + const seen = jest.fn() + k.on('query', seen) + try { + await expect( + readSnapshotJournalMetadataPage(k, { ...interval, ...change } as SnapshotJournalInterval) + ).rejects.toThrow() + expect(seen).not.toHaveBeenCalled() + } finally { + await k.destroy() + } + }) + + test.each([ + { present: 2 }, + { present: 'true' }, + { id1: 0 }, + { id2: -1 }, + { exactText: '😀'.repeat(101) }, + { exactText: Buffer.from([0xff]) }, + { generation: '0' }, + { generation: String(base + 2n) } + ])('invalid native metadata is never emitted: %p', async change => { + const k = await database() + try { + await k('snapshot_journal_physical').insert({ + tableId: 8, + id1: 1, + id2: 0, + exactText: '', + revision: String(base + 1n), + generation: String(base + 1n), + present: 1, + ...change + }) + await expect( + readSnapshotJournalMetadataPage(k, { ...interval, stream: 'physical', tableId: 8 }) + ).rejects.toThrow() + } finally { + await k.destroy() + } + }) + + test('generated bounded pages equal an independent exact integer and byte-order oracle', async () => { + const k = await database() + try { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray( + fc.record({ + userId: fc.integer({ min: 1, max: 2 }), + tableId: fc.constantFrom(3, 12), + id1: fc.integer({ min: 1, max: 8 }), + id2: fc.integer({ min: 0, max: 3 }), + exactText: fc.constantFrom('', 'a', 'A', 'a ', 'é', '😀', 'a\0b'), + offset: fc.integer({ min: 0, max: 11 }), + present: fc.boolean() + }), + { + maxLength: 40, + selector: row => JSON.stringify([row.userId, row.tableId, row.id1, row.id2, row.exactText]) + } + ), + fc.integer({ min: 1, max: 6 }), + async (records, limit) => { + await k('snapshot_journal_scope').delete() + const rows = records.map(({ offset, present, ...row }) => ({ + ...row, + revision: rev(String(base + BigInt(offset))), + present: Number(present) + })) + if (rows.length) await k('snapshot_journal_scope').insert(rows) + const actual = [] + let after: SnapshotJournalPosition | undefined + for (let pageIndex = 0; pageIndex <= rows.length; pageIndex++) { + const page = await readSnapshotJournalMetadataPage(k, { ...interval, limit, after }) + expect(page.examined).toBeLessThanOrEqual(limit) + actual.push(...page.rows) + if (page.complete) break + after = page.after + } + expect(actual).toEqual( + rows + .filter( + row => + row.tableId === 12 && + row.userId === 1 && + BigInt(row.revision) > base && + BigInt(row.revision) <= base + 10n + ) + .sort(compare) + .map(({ tableId: _table, userId: _user, present, ...row }) => ({ + ...row, + present: present === 1 + })) + ) + } + ), + propertyParameters + ) + } finally { + await k.destroy() + } + }) + + test.each(['oversized response', 'at lower bound', 'above upper bound', 'out of order', 'null text'])( + 'driver response integrity rejects %s before returning a page', + async kind => { + const k = await database() + const original = k.client.processResponse.bind(k.client) + const row = { revisionText: String(base + 1n), id1: 1, id2: 0, exactText: '', present: 1 } + const rows: Array> = [row] + if (kind === 'oversized response') rows.push({ ...row, id1: 2 }, { ...row, id1: 3 }) + if (kind === 'at lower bound') row.revisionText = interval.low + if (kind === 'above upper bound') row.revisionText = String(base + 11n) + if (kind === 'out of order') rows.push({ ...row }) + if (kind === 'null text') rows[0] = { ...row, exactText: null } + const spy = jest.spyOn(k.client, 'processResponse').mockImplementation((...args: unknown[]) => { + const query = args[0] as { sql: string } + return query.sql.includes('AS `j` INDEXED BY') ? rows : original(...args) + }) + try { + await expect(readSnapshotJournalMetadataPage(k, interval)).rejects.toThrow( + 'Invalid snapshot journal interval or metadata' + ) + expect(spy).toHaveBeenCalled() + } finally { + spy.mockRestore() + await k.destroy() + } + } + ) + test('cursor text must be a string before any SQL is issued', async () => { + const k = await database(), + seen = jest.fn() + k.on('query', seen) + try { + await expect( + readSnapshotJournalMetadataPage(k, { + ...interval, + after: { revision: interval.high, id1: 1, id2: 0, exactText: Buffer.from('a') } + } as unknown as SnapshotJournalInterval) + ).rejects.toThrow('Invalid snapshot journal interval or metadata') + expect(seen).not.toHaveBeenCalled() + } finally { + await k.destroy() + } + }) + test('page query refuses unsupported drivers before SQL', async () => { + const k = await database(), + seen = jest.fn() + k.client.config.client = 'unsupported' + k.on('query', seen) + try { + await expect(readSnapshotJournalMetadataPage(k, interval)).rejects.toThrow( + 'Unsupported snapshot journal SQL driver' + ) + expect(seen).not.toHaveBeenCalled() + } finally { + await k.destroy() + } + }) +}) + +describe('SnapshotJournalSqliteObservers', () => { + // Internal journal qualification; reader advertisement remains a separate gate. + + const q = (name: string) => '"' + name.replaceAll('"', '""') + '"' + const scopeKey = 'tableId,userId,id1,id2,exactText' + const physicalKey = 'tableId,id1,id2,exactText' + const tuple = (table: string, prefix: string) => { + const key = numeric.find(([name]) => name === table)?.[1] + if (key) return [prefix + '.' + q(key), '0', "''"] + if (table === 'tx_labels_map') return [prefix + '.txLabelId', prefix + '.transactionId', "''"] + if (table === 'output_tags_map') return [prefix + '.outputTagId', prefix + '.outputId', "''"] + return [prefix + '.certificateId', '0', prefix + '.fieldName'] + } + async function installCandidate(k: Knex, reverse: boolean) { + const definitions = await snapshotJournalSqliteObserverSql(k) + await installSnapshotJournalSqliteClock(k, snapshotJournalRevision('1000000000')) + for (const ddl of SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL) await k.raw(ddl) + const prior: Array<{ name: string; sql: string }> = await k('sqlite_master') + .where('type', 'trigger') + .whereIn('tbl_name', tables) + .select('name', 'sql') + for (const definition of definitions) await k.raw(definition) + if (reverse) + for (const trigger of prior) { + await k.raw('DROP TRIGGER ??', [trigger.name]) + await k.raw(trigger.sql) + } + } + async function expected(k: Knex) { + const selections = profiles.map( + ([table, key], id) => `SELECT ${id} tableId,userId,${q(key)} id1,0 id2,'' exactText FROM ${q(table)}` + ) + for (const [id, table, left, leftKey, right, rightKey] of [ + [10, 'tx_labels_map', 'tx_labels', 'txLabelId', 'transactions', 'transactionId'], + [11, 'output_tags_map', 'output_tags', 'outputTagId', 'outputs', 'outputId'] + ]) + for (const [parent, key] of [ + [left, leftKey], + [right, rightKey] + ]) + selections.push( + `SELECT ${id},p.userId,m.${leftKey},m.${rightKey},'' FROM ${table} m JOIN ${parent} p ON m.${key}=p.${key}` + ) + selections.push( + 'SELECT 12,userId,certificateId,0,fieldName FROM certificate_fields', + 'SELECT 12,c.userId,f.certificateId,0,f.fieldName FROM certificate_fields f JOIN certificates c ON c.certificateId=f.certificateId', + "SELECT 9,t.userId,r.provenTxReqId,0,'' FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid", + "SELECT 8,t.userId,p.provenTxId,0,'' FROM transactions t JOIN proven_txs p ON p.provenTxId=t.provenTxId", + "SELECT 8,t.userId,p.provenTxId,0,'' FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid JOIN proven_txs p ON p.provenTxId=r.provenTxId" + ) + return await k.raw( + 'SELECT * FROM (' + selections.join(' UNION ') + ') ORDER BY tableId,userId,id1,id2,exactText COLLATE BINARY' + ) + } + async function exact(k: Knex) { + expect( + await k('snapshot_journal_scope').where('present', 1).select(scopeKey.split(',')).orderBy(scopeKey.split(',')) + ).toEqual(await expected(k)) + for (const [tableId, table] of tables.entries()) { + const keys = tuple(table, 's') + const rows = await k.raw( + `SELECT s.*,g.generation AS candidateGeneration,g.present AS candidatePresent FROM ${q(table)} s LEFT JOIN snapshot_journal_physical g ON g.tableId=${tableId} AND g.id1=${keys[0]} AND g.id2=${keys[1]} AND g.exactText COLLATE BINARY=${keys[2]} COLLATE BINARY` + ) + for (const row of rows) { + expect(row.candidatePresent).toBe(1) + expect(row.candidateGeneration).toBeGreaterThan(0) + } + } + } + + test.each( + ['BINARY', 'NOCASE', 'RTRIM'].flatMap(collation => + [false, true].flatMap(reverse => [false, true].map(recursive => ({ collation, reverse, recursive }))) + ) + )( + 'all thirteen source/scope observers, collation=$collation order=$reverse recursion=$recursive', + async ({ collation, reverse, recursive }) => { + const k = await fixture(collation, recursive, false) + try { + for (const table of tables) + await k.schema.alterTable(table, t => { + void t.text('payload') + void t.integer('updated_at').notNullable().defaultTo(0) + }) + const plan = await installGeneration(k) + for (let n = 0; n < 100; n++) if ((await copyGenerationPage(k, plan)).complete) break + await installCandidate(k, reverse) + for (const table of tables) + for (const id of [1, 2]) + await k(table).insert({ + ...value(table, id, id, id), + payload: 'original' + }) + await exact(k) + const before = await k('snapshot_journal_physical').orderBy(physicalKey.split(',')) + for (const table of tables) await k(table).update({ payload: 'same-timestamp update' }) + await exact(k) + const after = await k('snapshot_journal_physical').orderBy(physicalKey.split(',')) + expect(after.map(r => r.generation)).toEqual(before.map(r => r.generation)) + after.forEach((r, i) => expect(r.revision).toBeGreaterThan(before[i].revision)) + for (const table of tables) await k(table).update({ payload: 'same-timestamp update' }) + expect(await k('snapshot_journal_physical').orderBy(physicalKey.split(','))).toEqual(after) + await k.raw( + 'CREATE TRIGGER candidate_nested_owner AFTER INSERT ON transactions WHEN NEW.userId=2 BEGIN UPDATE transactions SET userId=3 WHERE transactionId=NEW.transactionId; END' + ) + await replace(k, 'transactions', value('transactions', 1, 2, 2)) + await exact(k) + await k.raw( + "CREATE TRIGGER candidate_nested_reinsert AFTER UPDATE ON outputs WHEN NEW.payload='replace-inside' BEGIN DELETE FROM outputs WHERE outputId=NEW.outputId; INSERT INTO outputs(outputId,userId,transactionId,vout,payload,updated_at) VALUES(NEW.outputId,NEW.userId,NEW.transactionId,NEW.vout,'nested-new',NEW.updated_at); END" + ) + const oldGeneration = ( + await k('snapshot_journal_physical').where({ tableId: 1, id1: 1, id2: 0, exactText: '' }).first() + ).generation + await k('outputs').where('outputId', 1).update({ payload: 'replace-inside' }) + await exact(k) + expect( + (await k('snapshot_journal_physical').where({ tableId: 1, id1: 1, id2: 0, exactText: '' }).first()).generation + ).toBeGreaterThan(oldGeneration) + const field = await k('certificate_fields').where('certificateId', 1).first() + const renamed = collation === 'RTRIM' ? field.fieldName + ' ' : field.fieldName.toUpperCase() + await k('certificate_fields') + .where({ certificateId: 1, fieldName: field.fieldName }) + .update({ fieldName: renamed }) + await exact(k) + expect( + await k('snapshot_journal_scope').where({ + tableId: 12, + id1: 1, + exactText: field.fieldName, + present: 1 + }) + ).toEqual([]) + const originalLabel = await k('tx_labels').where('txLabelId', 1).first() + const labelGeneration = ( + await k('snapshot_journal_physical').where({ tableId: 3, id1: 1, id2: 0, exactText: '' }).first() + ).generation + await k('tx_labels').where('txLabelId', 1).update({ userId: 4 }) + await exact(k) + expect( + (await k('snapshot_journal_physical').where({ tableId: 3, id1: 1, id2: 0, exactText: '' }).first()).generation + ).toBe(labelGeneration) + expect( + ( + await k('snapshot_journal_scope') + .where({ + tableId: 3, + userId: originalLabel.userId, + id1: 1, + id2: 0, + exactText: '' + }) + .first() + ).present + ).toBe(0) + await k('tx_labels').where('txLabelId', 1).delete() + await exact(k) + await k('tx_labels').insert(originalLabel) + await exact(k) + expect( + (await k('snapshot_journal_physical').where({ tableId: 3, id1: 1, id2: 0, exactText: '' }).first()).generation + ).toBeGreaterThan(labelGeneration) + const globalScopes = await k('snapshot_journal_scope').where('tableId', 8).orderBy(scopeKey.split(',')) + const proofBefore = await k('snapshot_journal_physical') + .where({ tableId: 8, id1: 1, id2: 0, exactText: '' }) + .first() + await k('proven_txs').where('provenTxId', 1).update({ payload: 'new global payload with equal timestamp' }) + const proofAfter = await k('snapshot_journal_physical') + .where({ tableId: 8, id1: 1, id2: 0, exactText: '' }) + .first() + expect(proofAfter.generation).toBe(proofBefore.generation) + expect(proofAfter.revision).toBeGreaterThan(proofBefore.revision) + expect(await k('snapshot_journal_scope').where('tableId', 8).orderBy(scopeKey.split(','))).toEqual(globalScopes) + await exact(k) + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + table: fc.integer({ min: 0, max: 12 }), + kind: fc.integer({ min: 0, max: 3 }), + id: fc.integer({ min: 1, max: 5 }), + other: fc.integer({ min: 1, max: 5 }), + user: fc.integer({ min: 1, max: 3 }) + }), + { minLength: 1, maxLength: 10 } + ), + async ops => { + for (const op of ops) { + const table = tables[op.table] + if (op.kind === 0) await replace(k, table, value(table, op.id, op.other, op.user)) + else if (op.kind === 1) + await k(table) + .where(keyOf(table, op.id, op.other)) + .delete() + else { + const query = k(table) + .where(keyOf(table, op.id, op.other)) + .update(value(table, op.other, op.id, op.user)) + .toSQL() + await k.raw( + query.sql.replace(/^update/i, op.kind === 2 ? 'UPDATE OR REPLACE' : 'UPDATE OR IGNORE'), + query.bindings + ) + } + await exact(k) + } + } + ), + propertyParameters + ) + } finally { + await k.destroy() + } + }, + 60000 + ) + + test.each([false, true].flatMap(reverse => [false, true].map(recursive => ({ reverse, recursive }))))( + 'actual migrated schema and independent WAL view, order=$reverse recursion=$recursive', + async ({ reverse, recursive }) => { + const directory = await mkdtemp(join(tmpdir(), 'ts569-journal-actual-')) + const filename = join(directory, 'wallet.sqlite') + const k = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const reader = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const source = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: k + }) + let view: Knex.Transaction | undefined + try { + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('candidate journal fixture', 'synthetic-candidate-journal') + await source.makeAvailable() + await k.raw('PRAGMA recursive_triggers=' + Number(recursive)) + const users = [] + for (const identity of ['02' + '11'.repeat(32), '03' + '22'.repeat(32)]) + users.push((await source.findOrInsertUser(identity)).user.userId) + await installCandidate(k, reverse) + expect(await readGenerationIndexState(k)).toBe('v2') + await seedArchiveClosure(source, users[0], users[1]) + await exact(k) + const physicalBefore = await k('snapshot_journal_physical').orderBy(physicalKey.split(',')) + const scopeBefore = await k('snapshot_journal_scope').orderBy(scopeKey.split(',')) + const highBefore = (await k('snapshot_journal_clock').first()).revision + view = await reader.transaction() + expect((await view('snapshot_journal_clock').first()).revision).toBe(highBefore) + for (const table of tables) { + const timestamps = await k(table).select('updated_at') + await k(table).update({ created_at: '2026-01-01T00:00:00.001Z' }) + expect(await k(table).select('updated_at')).toEqual(timestamps) + } + await exact(k) + expect(await view('snapshot_journal_physical').orderBy(physicalKey.split(','))).toEqual(physicalBefore) + expect(await view('snapshot_journal_scope').orderBy(scopeKey.split(','))).toEqual(scopeBefore) + const physicalAfter = await k('snapshot_journal_physical').orderBy(physicalKey.split(',')) + expect(physicalAfter.map(r => r.generation)).toEqual(physicalBefore.map(r => r.generation)) + expect( + new Set((await k('snapshot_journal_physical').where('revision', '>', highBefore)).map(r => r.tableId)) + ).toEqual(new Set(tables.map((_, id) => id))) + await view.rollback() + view = undefined + const beforeRollback = await k('snapshot_journal_physical').orderBy(physicalKey.split(',')) + const rollbackHigh = (await k('snapshot_journal_clock').first()).revision + await k.transaction(async trx => { + await trx('tx_labels').where('txLabelId', 1).update({ label: 'rolled-back label' }) + await trx.rollback() + }) + expect(await k('snapshot_journal_physical').orderBy(physicalKey.split(','))).toEqual(beforeRollback) + expect((await k('snapshot_journal_clock').first()).revision).toBe(rollbackHigh) + for (const [tableId, table] of tables.entries()) { + const original = await k(table).first() + const key = numeric[tableId]?.[1] + const identity = { + tableId, + id1: key + ? original[key] + : table === 'certificate_fields' + ? original.certificateId + : table === 'tx_labels_map' + ? original.txLabelId + : original.outputTagId, + id2: + table === 'tx_labels_map' ? original.transactionId : table === 'output_tags_map' ? original.outputId : 0, + exactText: table === 'certificate_fields' ? original.fieldName : '' + } + const before = await k('snapshot_journal_physical').where(identity).first() + await replace(k, table, original) + await exact(k) + const after = await k('snapshot_journal_physical').where(identity).first() + expect(after.generation).toBeGreaterThan(before.generation) + } + expect(await k.raw('PRAGMA foreign_key_check')).toEqual([]) + } finally { + if (view) await view.rollback() + await reader.destroy() + await source.destroy() + await rm(directory, { recursive: true, force: true }) + } + }, + 60000 + ) + + async function journalFixture(): Promise { + const k = await fixture('BINARY', false, false) + const plan = await installGeneration(k) + for (let n = 0; n < 100; n++) if ((await copyGenerationPage(k, plan)).complete) break + await installCandidate(k, false) + return k + } + + test('all thirteen observers preserve exact revision and generation values above 2^53', async () => { + const k = await journalFixture() + try { + await k('snapshot_journal_clock').update({ + ceiling: '9223372036854775807', + revision: '9007199254740992' + }) + for (const table of tables) await k(table).insert(value(table, 1, 1, 1)) + const rows = await k('snapshot_journal_physical') + .select('tableId', k.raw('CAST(revision AS TEXT) revision'), k.raw('CAST(generation AS TEXT) generation')) + .orderBy('tableId') + expect(rows.map(row => row.tableId)).toEqual(Array.from({ length: 13 }, (_, i) => i)) + for (const row of rows) { + expect(typeof row.revision).toBe('string') + expect(BigInt(row.revision)).toBeGreaterThan(9007199254740992n) + expect(row.generation).toBe(row.revision) + } + expect(await k('snapshot_journal_clock').first('enabled', 'reason')).toEqual({ + enabled: 1, + reason: null + }) + } finally { + await k.destroy() + } + }) + + test.each(['capacity', 'signed63'])('%s exhaustion preserves all thirteen standard source writes', async kind => { + const k = await journalFixture() + try { + await k('snapshot_journal_clock').update( + kind === 'capacity' ? { ceiling: 1 } : { ceiling: '9223372036854775807', revision: '9223372036854775806' } + ) + for (const table of tables) await k(table).insert(value(table, 1, 1, 1)) + for (const table of tables) expect(await k(table).count('* AS n').first()).toEqual({ n: 1 }) + expect(await k('snapshot_journal_clock').first('enabled', 'reason')).toEqual({ + enabled: 0, + reason: kind === 'capacity' ? 'capacity-exhausted' : 'revision-exhausted' + }) + const before = await k('snapshot_journal_physical').select('tableId', 'id1', 'id2', 'exactText') + await k('tx_labels').insert(value('tx_labels', 2, 2, 2)) + expect(await k('snapshot_journal_physical').select('tableId', 'id1', 'id2', 'exactText')).toEqual(before) + } finally { + await k.destroy() + } + }) + + test('400-byte field keys remain exact and 401 bytes invalidate without losing source data', async () => { + const k = await journalFixture() + try { + const accepted = '😀'.repeat(100), + oversized = accepted + 'x' + await k('certificates').insert(value('certificates', 1, 1, 1)) + await k('certificate_fields').insert({ + ...value('certificate_fields', 1, 1, 1), + fieldName: accepted + }) + expect(await k('snapshot_journal_clock').first('enabled')).toEqual({ + enabled: 1 + }) + expect((await k('snapshot_journal_scope').where({ tableId: 12, userId: 1 }).first()).exactText).toBe(accepted) + await k('certificate_fields').insert({ + ...value('certificate_fields', 1, 1, 1), + fieldName: oversized + }) + expect(await k('certificate_fields').pluck('fieldName')).toEqual([accepted, oversized]) + expect(await k('snapshot_journal_clock').first('enabled', 'reason')).toEqual({ + enabled: 0, + reason: 'key-out-of-range' + }) + expect(await k('snapshot_journal_physical').where('exactText', oversized)).toEqual([]) + } finally { + await k.destroy() + } + }) + + test('an out-of-range numeric key invalidates atomically and rollback restores every observer', async () => { + const k = await journalFixture() + try { + const before = await k('snapshot_journal_clock').first() + await expect( + k.transaction(async t => { + await t.raw("INSERT INTO tx_labels(txLabelId,userId,label) VALUES(9007199254740992,1,'beyond exact wire ID')") + expect(await t('snapshot_journal_clock').first('enabled', 'reason')).toEqual({ + enabled: 0, + reason: 'key-out-of-range' + }) + throw new Error('rollback invalidation') + }) + ).rejects.toThrow('rollback invalidation') + expect(await k('snapshot_journal_clock').first()).toEqual(before) + expect(await k('snapshot_journal_scope')).toEqual([]) + expect(await k('snapshot_journal_physical')).toEqual([]) + expect(await k('tx_labels')).toEqual([]) + await k.raw( + "INSERT INTO tx_labels(txLabelId,userId,label) VALUES(9007199254740992,1,'committed beyond exact wire ID')" + ) + expect(await k('tx_labels').count('* AS n').first()).toEqual({ n: 1 }) + expect(await k('snapshot_journal_clock').first('enabled', 'reason')).toEqual({ + enabled: 0, + reason: 'key-out-of-range' + }) + } finally { + await k.destroy() + } + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts new file mode 100644 index 000000000..70c1c831e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts @@ -0,0 +1,322 @@ +import { copySnapshotJournalBootstrapPage, SNAPSHOT_JOURNAL_BOOTSTRAP_DDL } from './SnapshotJournalBootstrap' +import { + snapshotJournalSqliteObserverSql, + SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL +} from './SnapshotJournalSqliteObservers' +import { installSnapshotJournalSqliteClock } from './SnapshotJournalSqliteClock' +import { snapshotJournalRevision } from './SnapshotJournalRevision' +// Outside-checkout design experiment. This is not a registered migration or API. +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' +import { fixture, value, tables, numeric, profiles } from '../../../../test/utils/snapshotSqliteFixtures' +import { installGeneration } from '../../schema/snapshotSqliteIndexGeneration' +import { copyGenerationPage } from '../../schema/snapshotSqliteIndexBootstrap' + +const q = (name: string) => '"' + name.replaceAll('"', '""') + '"' +const scopeKey = 'tableId,userId,id1,id2,exactText' +const tuple = (table: string, prefix: string) => { + const key = numeric.find(([name]) => name === table)?.[1] + if (key) return [prefix + '.' + q(key), '0', "''"] + if (table === 'tx_labels_map') return [prefix + '.txLabelId', prefix + '.transactionId', "''"] + if (table === 'output_tags_map') return [prefix + '.outputTagId', prefix + '.outputId', "''"] + return [prefix + '.certificateId', '0', prefix + '.fieldName'] +} +async function installCandidate(k: Knex, reverse: boolean) { + const definitions = await snapshotJournalSqliteObserverSql(k) + await installSnapshotJournalSqliteClock(k, snapshotJournalRevision('1000000000')) + for (const ddl of SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL) await k.raw(ddl) + const prior: Array<{ name: string; sql: string }> = await k('sqlite_master') + .where('type', 'trigger') + .whereIn('tbl_name', tables) + .select('name', 'sql') + for (const definition of definitions) await k.raw(definition) + if (reverse) + for (const trigger of prior) { + await k.raw('DROP TRIGGER ??', [trigger.name]) + await k.raw(trigger.sql) + } +} +async function expected(k: Knex) { + const selections = profiles.map( + ([table, key], id) => `SELECT ${id} tableId,userId,${q(key)} id1,0 id2,'' exactText FROM ${q(table)}` + ) + for (const [id, table, left, leftKey, right, rightKey] of [ + [10, 'tx_labels_map', 'tx_labels', 'txLabelId', 'transactions', 'transactionId'], + [11, 'output_tags_map', 'output_tags', 'outputTagId', 'outputs', 'outputId'] + ]) + for (const [parent, key] of [ + [left, leftKey], + [right, rightKey] + ]) + selections.push( + `SELECT ${id},p.userId,m.${leftKey},m.${rightKey},'' FROM ${table} m JOIN ${parent} p ON m.${key}=p.${key}` + ) + selections.push( + 'SELECT 12,userId,certificateId,0,fieldName FROM certificate_fields', + 'SELECT 12,c.userId,f.certificateId,0,f.fieldName FROM certificate_fields f JOIN certificates c ON c.certificateId=f.certificateId', + "SELECT 9,t.userId,r.provenTxReqId,0,'' FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid", + "SELECT 8,t.userId,p.provenTxId,0,'' FROM transactions t JOIN proven_txs p ON p.provenTxId=t.provenTxId", + "SELECT 8,t.userId,p.provenTxId,0,'' FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid JOIN proven_txs p ON p.provenTxId=r.provenTxId" + ) + return await k.raw( + 'SELECT * FROM (' + selections.join(' UNION ') + ') ORDER BY tableId,userId,id1,id2,exactText COLLATE BINARY' + ) +} +async function exact(k: Knex) { + expect( + await k('snapshot_journal_scope').where('present', 1).select(scopeKey.split(',')).orderBy(scopeKey.split(',')) + ).toEqual(await expected(k)) + for (const [tableId, table] of tables.entries()) { + const keys = tuple(table, 's') + const rows = await k.raw( + `SELECT s.*,g.generation AS candidateGeneration,g.present AS candidatePresent FROM ${q(table)} s LEFT JOIN snapshot_journal_physical g ON g.tableId=${tableId} AND g.id1=${keys[0]} AND g.id2=${keys[1]} AND g.exactText COLLATE BINARY=${keys[2]} COLLATE BINARY` + ) + for (const row of rows) { + expect(row.candidatePresent).toBe(1) + expect(row.candidateGeneration).toBeGreaterThan(0) + } + } +} + +async function beginBootstrap(k: Knex) { + await installCandidate(k, false) + await k.raw(SNAPSHOT_JOURNAL_BOOTSTRAP_DDL) + await k('snapshot_journal_bootstrap').insert({ id: 1, stream: 0, cursor: null }) +} + +test('exact bootstrap copies bounded pages, resumes and preserves newer low-key observers', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ts569-exact-bootstrap-')), + filename = join(directory, 'source.sqlite') + const k = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + const writer = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + try { + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('exact bootstrap fixture', 'synthetic-exact-bootstrap') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)), + { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, other.userId) + const original = await k('tx_labels').where('txLabelId', 1).first() + for (let start = 1000; start < 1600; start += 100) + await k('tx_labels').insert( + Array.from({ length: 100 }, (_, offset) => ({ + ...original, + txLabelId: start + offset, + label: 'bootstrap-' + (start + offset) + })) + ) + await beginBootstrap(k) + await k('snapshot_journal_clock').update({ + revision: '9007199254740992', + ceiling: '9223372036854775807' + }) + let moved = false, + complete = false, + max = 0, + insertQueries = 0 + const count = (q: { sql: string }) => { + if (/^insert into `snapshot_journal_(physical|scope)`/i.test(q.sql)) insertQueries++ + } + k.on('query', count) + writer.on('query', count) + for (let page = 0; page < 100; page++) { + insertQueries = 0 + const result = await copySnapshotJournalBootstrapPage(page % 2 ? writer : k) + expect(insertQueries).toBeLessThanOrEqual(4) + expect(result.invalidated).toBe(false) + max = Math.max(max, result.selected) + if (result.stream === 3 && result.selected === 256 && !moved) { + moved = true + await writer('tx_labels').where('txLabelId', 1).update({ label: 'changed below cursor' }) + await writer('tx_labels').where('txLabelId', 1000).delete() + await writer('tx_labels').insert({ + ...original, + txLabelId: 999, + label: 'inserted below cursor' + }) + await writer('tx_labels').where('txLabelId', 1599).update({ userId: other.userId }) + } + if (result.complete) { + complete = true + break + } + } + expect({ moved, complete, max }).toEqual({ moved: true, complete: true, max: 256 }) + await exact(k) + expect((await k('snapshot_journal_physical').where({ tableId: 3, id1: 1000 }).first()).present).toBe(0) + expect( + (await k('snapshot_journal_scope').where({ tableId: 3, id1: 1000, userId: user.userId }).first()).present + ).toBe(0) + const revisions = await k('snapshot_journal_physical').select( + k.raw('CAST(revision AS TEXT) revision'), + k.raw('CAST(generation AS TEXT) generation') + ) + for (const row of revisions) { + expect(BigInt(row.revision)).toBeGreaterThan(9007199254740992n) + expect(BigInt(row.generation)).toBeGreaterThan(9007199254740992n) + } + expect(await copySnapshotJournalBootstrapPage(k)).toEqual({ + complete: true, + selected: 0, + stream: 17, + invalidated: false + }) + expect(await k.raw('PRAGMA foreign_key_check')).toEqual([]) + } finally { + await writer.destroy() + await source.destroy() + await rm(directory, { recursive: true, force: true }) + } +}, 60000) + +async function emptyFixture(): Promise { + const k = await fixture('BINARY', false, false) + const plan = await installGeneration(k) + for (let i = 0; i < 100; i++) if ((await copyGenerationPage(k, plan)).complete) break + return k +} + +test('oversized historical text is bounded before decode and disables bootstrap without losing source data', async () => { + const k = await emptyFixture() + try { + const text = 'x'.repeat(1000000) + await k('certificate_fields').insert({ + ...value('certificate_fields', 1, 1, 1), + fieldName: text + }) + await beginBootstrap(k) + await k('snapshot_journal_bootstrap').update({ stream: 12 }) + let maximum = 0 + const record = (response: unknown) => { + if (Array.isArray(response)) + for (const row of response) + if (row?.boundedText instanceof Uint8Array) maximum = Math.max(maximum, row.boundedText.length) + } + k.on('query-response', record) + const page = await copySnapshotJournalBootstrapPage(k) + k.off('query-response', record) + expect(page).toEqual({ complete: false, selected: 1, stream: 12, invalidated: true }) + expect(maximum).toBe(401) + expect(await k('snapshot_journal_clock').first('enabled', 'reason')).toEqual({ + enabled: 0, + reason: 'key-out-of-range' + }) + expect(await k('snapshot_journal_physical')).toEqual([]) + expect(await k('certificate_fields').select(k.raw('length(fieldName) n')).first()).toEqual({ + n: 1000000 + }) + expect(await k('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 12, + cursor: null + }) + } finally { + await k.destroy() + } +}) + +test.each(['capacity', 'signed63'])('%s exhaustion commits invalidation without publishing progress', async kind => { + const k = await emptyFixture() + try { + await k('transactions').insert(value('transactions', 1, 1, 1)) + await beginBootstrap(k) + await k('snapshot_journal_clock').update( + kind === 'capacity' + ? { revision: 1, ceiling: 1 } + : { revision: '9223372036854775807', ceiling: '9223372036854775807' } + ) + expect(await copySnapshotJournalBootstrapPage(k)).toEqual({ + complete: false, + selected: 1, + stream: 0, + invalidated: true + }) + expect(await k('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 0, + cursor: null + }) + expect(await k('snapshot_journal_physical')).toEqual([]) + expect(await k('transactions').count('* AS n').first()).toEqual({ n: 1 }) + } finally { + await k.destroy() + } +}) + +test.each(['{', '{}', '[]', '["wrong-type"]', '[9007199254740992]', '[1,2]'])( + 'malformed persisted cursor %s refuses before progress', + async cursor => { + const k = await emptyFixture() + try { + await beginBootstrap(k) + await k('snapshot_journal_bootstrap').update({ cursor }) + await expect(copySnapshotJournalBootstrapPage(k)).rejects.toThrow('bootstrap state') + expect(await k('snapshot_journal_physical')).toEqual([]) + } finally { + await k.destroy() + } + } +) + +test('historical BLOB field spelling invalidates instead of changing its SQL comparison domain', async () => { + const k = await emptyFixture() + try { + await k.raw( + "INSERT INTO certificate_fields(userId,certificateId,fieldName,fieldValue) VALUES(1,1,CAST('field' AS BLOB),'value')" + ) + await beginBootstrap(k) + await k('snapshot_journal_bootstrap').update({ stream: 12 }) + expect((await copySnapshotJournalBootstrapPage(k)).invalidated).toBe(true) + expect(await k('snapshot_journal_physical')).toEqual([]) + expect(await k('certificate_fields').select(k.raw('typeof(fieldName) kind')).first()).toEqual({ + kind: 'blob' + }) + } finally { + await k.destroy() + } +}) + +test('malformed SQLite clock response rolls back allocation and bootstrap progress', async () => { + const k = await emptyFixture() + let altered = false + const listener = (row: { enabled: number }, query: { sql: string }) => { + if (query.sql.startsWith('select `enabled`') && query.sql.includes('as `value`')) { + row.enabled = 2 + altered = true + } + } + try { + await k('transactions').insert(value('transactions', 1, 1, 1)) + await beginBootstrap(k) + const before = await k('snapshot_journal_clock').first() + k.on('query-response', listener) + await expect(copySnapshotJournalBootstrapPage(k)).rejects.toThrow('Invalid snapshot') + expect(altered).toBe(true) + expect(await k('snapshot_journal_clock').first()).toEqual(before) + expect(await k('snapshot_journal_physical')).toEqual([]) + expect(await k('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 0, + cursor: null + }) + } finally { + k.off('query-response', listener) + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts new file mode 100644 index 000000000..ea89557fc --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts @@ -0,0 +1,347 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { numeric, legacyNames } from '../../schema/snapshotSqliteMembership' +import { names } from '../../schema/snapshotSqliteIndexGeneration' +import { SNAPSHOT_JOURNAL_SQLITE_ADVANCE } from './SnapshotJournalSqliteClock' +import { + compareSnapshotJournalRevisions, + MAX_SNAPSHOT_JOURNAL_REVISION, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' +import { snapshotJournalRevisionText, snapshotJournalRevisionOperand } from './SnapshotJournalRevisionSql' + +export const SNAPSHOT_JOURNAL_BOOTSTRAP_DDL = + 'CREATE TABLE snapshot_journal_bootstrap(id INTEGER NOT NULL PRIMARY KEY,stream INTEGER NOT NULL,`cursor` TEXT,CHECK(id=1),CHECK(stream BETWEEN 0 AND 17))' +interface Stream { + table: string + keys: string[] + text?: string + extra?: string + physical: boolean + record: (row: Record) => Record +} +function invalid(): never { + throw new WERR_INVALID_OPERATION('Invalid snapshot journal bootstrap state or source order') +} +function sqlite(k: Knex): boolean { + const client = k.client.config.client + if (client === 'better-sqlite3' || client === 'sqlite3') return true + if (client === 'mysql' || client === 'mysql2') return false + return invalid() +} +function streams(local: boolean): Stream[] { + const membership = local ? names : legacyNames + const tables = [...numeric.map(source => source.table), 'tx_labels_map', 'output_tags_map', 'certificate_fields'] + const physical: Stream[] = tables.map((table, tableId) => { + const key = numeric[tableId]?.key + const keys = key + ? [key] + : table === 'tx_labels_map' + ? ['txLabelId', 'transactionId'] + : table === 'output_tags_map' + ? ['outputTagId', 'outputId'] + : ['fieldName', 'certificateId'] + return { + table, + keys, + text: tableId === 12 ? 'fieldName' : undefined, + physical: true, + record: r => ({ + tableId, + id1: key ? r[key] : tableId === 10 ? r.txLabelId : tableId === 11 ? r.outputTagId : r.certificateId, + id2: tableId === 10 ? r.transactionId : tableId === 11 ? r.outputId : 0, + exactText: tableId === 12 ? r.fieldName : '', + present: 1 + }) + } + }) + return [ + ...physical, + { + table: membership.profile, + keys: ['snapshotTableId', 'snapshotUserId', 'snapshotRowId'], + physical: false, + record: r => ({ + tableId: r.snapshotTableId, + userId: r.snapshotUserId, + id1: r.snapshotRowId, + id2: 0, + exactText: '', + present: 1 + }) + }, + { + table: membership.relation, + keys: ['snapshotTableId', 'snapshotUserId', 'snapshotLeftId', 'snapshotRightId'], + extra: 'snapshotMembership', + physical: false, + record: r => ({ + tableId: Number(r.snapshotTableId) + 10, + userId: r.snapshotUserId, + id1: r.snapshotLeftId, + id2: r.snapshotRightId, + exactText: '', + present: Number(r.snapshotMembership) !== 0 ? 1 : 0 + }) + }, + { + table: membership.certificate, + keys: ['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId'], + text: 'snapshotFieldName', + extra: 'snapshotMembership', + physical: false, + record: r => ({ + tableId: 12, + userId: r.snapshotUserId, + id1: r.snapshotCertificateId, + id2: 0, + exactText: r.snapshotFieldName, + present: Number(r.snapshotMembership) !== 0 ? 1 : 0 + }) + }, + { + table: membership.keys, + keys: ['tableId', 'userId', 'rowId'], + extra: 'present', + physical: false, + record: r => ({ + tableId: r.tableId === 0 ? 9 : 8, + userId: r.userId, + id1: r.rowId, + id2: 0, + exactText: '', + present: Number(r.present) + }) + } + ] +} +function validKey(record: Record): boolean { + const integer = (value: unknown, min: number, max = Number.MAX_SAFE_INTEGER) => + typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max + const text = record.exactText + return ( + integer(record.tableId, 0, 12) && + integer(record.id1, 1) && + integer(record.id2, 0) && + (record.userId === undefined || integer(record.userId, 1)) && + typeof text === 'string' && + Buffer.byteLength(text, 'utf8') <= 400 && + Buffer.from(text, 'utf8').toString('utf8') === text && + (record.present === 0 || record.present === 1) + ) +} +async function invalidate(k: Knex, local: boolean, reason: string): Promise { + if (local) await k('snapshot_journal_clock').where({ id: 1, enabled: 1 }).update({ enabled: 0, reason }) + else await k('snapshot_journal_invalid').insert({ id: 1, reason }).onConflict('id').ignore() +} +async function revision(k: Knex, local: boolean): Promise { + if (local) { + await k.raw(SNAPSHOT_JOURNAL_SQLITE_ADVANCE) + const row = await k('snapshot_journal_clock') + .where('id', 1) + .select('enabled', { value: snapshotJournalRevisionText(k, 'revision') }) + .first() + if (!row || ![0, 1].includes(row.enabled)) return invalid() + return row.enabled === 1 ? snapshotJournalRevision(row.value) : undefined + } + const row = await k('snapshot_journal_clock') + .where('id', 1) + .select({ ceiling: snapshotJournalRevisionText(k, 'ceiling') }) + .first() + if (!row) return invalid() + const ceiling = snapshotJournalRevision(row.ceiling) + await k('snapshot_journal_events').insert({}) + const [[allocated]]: Array> = await k.raw('SELECT CAST(LAST_INSERT_ID() AS CHAR) value') + if (!allocated || typeof allocated.value !== 'string' || !/^\d+$/.test(allocated.value)) return invalid() + await k('snapshot_journal_events').where('revision', allocated.value).delete() + if (BigInt(allocated.value) > BigInt(MAX_SNAPSHOT_JOURNAL_REVISION)) { + await invalidate(k, local, 'revision-exhausted') + return undefined + } + const value = snapshotJournalRevision(allocated.value) + if (compareSnapshotJournalRevisions(value, ceiling) > 0) { + await invalidate(k, local, 'capacity-exhausted') + return undefined + } + return value +} +async function queryPage( + k: Knex, + stream: Stream, + cursor: Array | undefined, + local: boolean +): Promise>> { + let query = k.from({ s: stream.table }) + if (!local) { + const [parts]: Array> = await k.raw( + 'SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX', + [stream.table] + ) + const indexes = new Map() + for (const part of parts) { + if (!indexes.has(part.name)) indexes.set(part.name, []) + indexes.get(part.name)!.push(part.field) + } + const index = [...indexes].find(([, fields]) => JSON.stringify(fields) === JSON.stringify(stream.keys))?.[0] + if (!index) return invalid() + query = k.from(k.raw('?? AS ?? FORCE INDEX (??)', [stream.table, 's', index])).forShare() + } + for (const key of stream.keys) { + if (key === stream.text) { + if (local) query.select(k.raw('typeof(??) AS ??', ['s.' + key, 'boundedTextType'])) + query.select( + k.raw(local ? 'substr(CAST(?? AS BLOB),1,401) AS ??' : 'SUBSTRING(CAST(?? AS BINARY),1,401) AS ??', [ + 's.' + key, + 'boundedText' + ]) + ) + } else query.select('s.' + key) + } + if (stream.extra) query.select('s.' + stream.extra) + query.orderBy(stream.keys.map(key => 's.' + key)).limit(256) + if (cursor) { + if (local) + query.whereRaw( + '(' + stream.keys.map(() => '??').join(',') + ') > (' + stream.keys.map(() => '?').join(',') + ')', + [...stream.keys.map(key => 's.' + key), ...cursor] + ) + else + query.where(function () { + stream.keys.forEach((key, index) => { + this.orWhere(function () { + for (let i = 0; i < index; i++) this.where('s.' + stream.keys[i], cursor[i]) + this.where('s.' + key, '>', cursor[index]) + }) + }) + }) + } + const sql = query.toSQL() + const plan = await k.raw((local ? 'EXPLAIN QUERY PLAN ' : 'EXPLAIN ') + sql.sql, sql.bindings as Knex.RawBinding[]) + if ( + (local ? plan : plan[0]).some((step: { detail?: string; Extra?: string }) => + local ? step.detail?.includes('TEMP B-TREE') : step.Extra?.includes('filesort') + ) + ) + return invalid() + const rows: Array> = await query + if (rows.length > 256) return invalid() + for (const row of rows) + if (stream.text) { + const bytes = row.boundedText + if (!(bytes instanceof Uint8Array)) return invalid() + const text = Buffer.from(bytes).toString('utf8') + row[stream.text] = + (!local || row.boundedTextType === 'text') && Buffer.from(text, 'utf8').equals(Buffer.from(bytes)) + ? text + : undefined + delete row.boundedTextType + delete row.boundedText + } + return rows +} + +export interface SnapshotJournalBootstrapPage { + complete: boolean + selected: number + stream: number + invalidated: boolean +} +/** Fresh/resumed owned state only. Caller must validate migration ownership before every resume. */ +export async function copySnapshotJournalBootstrapPage(k: Knex): Promise { + const local = sqlite(k), + all = streams(local) + return await k.transaction(async t => { + if (local) + await t('snapshot_journal_bootstrap') + .where('id', 1) + .update({ stream: t.ref('stream') }) + else if (!(await t('snapshot_journal_clock').where('id', 1).forUpdate().noWait().first('id'))) return invalid() + const state: { stream: number; cursor: string | null } | undefined = await t('snapshot_journal_bootstrap') + .where('id', 1) + .first() + if ( + !state || + !Number.isInteger(state.stream) || + state.stream < 0 || + state.stream > all.length || + !(state.cursor === null || typeof state.cursor === 'string') + ) + return invalid() + const enabled = local + ? (await t('snapshot_journal_clock').where('id', 1).first('enabled'))?.enabled === 1 + : !(await t('snapshot_journal_invalid').where('id', 1).first('id')) + if (!enabled) return { complete: false, selected: 0, stream: state.stream, invalidated: true } + if (state.stream === all.length) { + if (state.cursor !== null) return invalid() + return { complete: true, selected: 0, stream: state.stream, invalidated: false } + } + const stream = all[state.stream] + let cursor: Array | undefined + if (state.cursor !== null) { + if (Buffer.byteLength(state.cursor) > 2048) return invalid() + let parsed: unknown + try { + parsed = JSON.parse(state.cursor) + } catch { + return invalid() + } + if (!Array.isArray(parsed) || parsed.length !== stream.keys.length) return invalid() + for (const [i, value] of parsed.entries()) + if ( + stream.keys[i] === stream.text + ? typeof value !== 'string' || Buffer.byteLength(value) > 400 + : typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 + ) + return invalid() + cursor = parsed as Array + } + const rows = await queryPage(t, stream, cursor, local), + records = rows.map(row => stream.record(row)) + if (records.some(record => !validKey(record))) { + await invalidate(t, local, 'key-out-of-range') + return { complete: false, selected: rows.length, stream: state.stream, invalidated: true } + } + if (records.length) { + const at = await revision(t, local) + if (at === undefined) return { complete: false, selected: rows.length, stream: state.stream, invalidated: true } + const table = stream.physical ? 'snapshot_journal_physical' : 'snapshot_journal_scope' + const primary = stream.physical + ? ['tableId', 'id1', 'id2', 'exactText'] + : ['tableId', 'userId', 'id1', 'id2', 'exactText'] + // At most four writes per page, each below the 999-binding SQLite floor. + const batches = Array.from({ length: Math.ceil(records.length / 64) }, (_, i) => + records.slice(i * 64, (i + 1) * 64) + ) + await runInSeries(batches, async batch => { + const query = t(table) + .insert( + batch.map(record => ({ + ...record, + exactText: local ? record.exactText : Buffer.from(record.exactText as string), + revision: snapshotJournalRevisionOperand(t, at), + ...(stream.physical ? { generation: snapshotJournalRevisionOperand(t, at) } : {}) + })) + ) + .onConflict(primary) + if (local) await query.ignore() + else await query.merge({ id1: t.ref(table + '.id1') }) + }) + } + const last = rows.at(-1) + await t('snapshot_journal_bootstrap') + .where('id', 1) + .update( + last + ? { cursor: JSON.stringify(stream.keys.map(key => last[key])) } + : { stream: state.stream + 1, cursor: null } + ) + return { + complete: !last && state.stream + 1 === all.length, + selected: rows.length, + stream: state.stream, + invalidated: false + } + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.test.ts new file mode 100644 index 000000000..47cd2634a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.test.ts @@ -0,0 +1,157 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readSnapshotJournalHighWater } from './SnapshotJournalHighWater' +import { snapshotJournalRevision as rev, type SnapshotJournalRevision } from './SnapshotJournalRevision' +import { SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL } from './SnapshotJournalSqliteObservers' + +function open(filename = ':memory:'): Knex { + return knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) +} +async function install(k: Knex) { + for (const sql of SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL) await k.raw(sql) +} + +test('high-water uses fifteen indexed single-row reads, exact values and only the bound profile plus global streams', async () => { + const k = open() + try { + await install(k) + await k('snapshot_journal_scope').insert([ + { + tableId: 0, + userId: 1, + id1: 1, + id2: 0, + exactText: '', + present: 1, + revision: '9007199254740993' + }, + { + tableId: 12, + userId: 1, + id1: 2, + id2: 0, + exactText: 'field', + present: 0, + revision: '9007199254740995' + }, + { + tableId: 0, + userId: 2, + id1: 3, + id2: 0, + exactText: '', + present: 1, + revision: '9223372036854775807' + } + ]) + await k('snapshot_journal_physical').insert([ + { + tableId: 8, + id1: 1, + id2: 0, + exactText: '', + present: 1, + revision: '9007199254740997', + generation: '9007199254740997' + }, + { + tableId: 0, + id1: 3, + id2: 0, + exactText: '', + present: 1, + revision: '9223372036854775807', + generation: '9223372036854775807' + } + ]) + const queries: Array<{ sql: string; bindings: unknown[] }> = [] + const onQuery = (q: { sql: string; bindings: unknown[] }) => { + if (q.sql.startsWith('select')) queries.push(q) + } + k.on('query', onQuery) + expect(await readSnapshotJournalHighWater(k, 1, rev('0'), rev('0'))).toBe('9007199254740997') + k.off('query', onQuery) + expect(queries).toHaveLength(15) + for (const query of queries) { + const plan: Array<{ detail: string }> = await k.raw( + 'EXPLAIN QUERY PLAN ' + query.sql, + query.bindings as Knex.RawBinding[] + ) + expect(plan.some(row => row.detail.includes('SEARCH j USING COVERING INDEX'))).toBe(true) + expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false) + expect(query.bindings.at(-1)).toBe(1) + } + expect(await readSnapshotJournalHighWater(k, 1, rev('9223372036854775807'), rev('9007199254740997'))).toBe( + '9223372036854775807' + ) + } finally { + await k.destroy() + } +}) + +test('empty and quiescent streams retain the exact committed prefix', async () => { + const k = open() + try { + await install(k) + expect(await readSnapshotJournalHighWater(k, 1, rev('9007199254740993'), rev('0'))).toBe('9007199254740993') + } finally { + await k.destroy() + } +}) + +test.each([ + { id: 0, minimum: '0', floor: '0' }, + { id: 1.1, minimum: '0', floor: '0' }, + { id: 1, minimum: '01', floor: '0' }, + { id: 1, minimum: '1', floor: '2' } +])('invalid profile or stale prefix refuses before SQL: %j', async ({ id, minimum, floor }) => { + const k = open() + let queries = 0 + k.on('query', () => queries++) + try { + await expect( + readSnapshotJournalHighWater(k, id, minimum as SnapshotJournalRevision, floor as SnapshotJournalRevision) + ).rejects.toThrow() + expect(queries).toBe(0) + } finally { + await k.destroy() + } +}) + +test('pinned WAL heads remain coherent after an independent writer commits', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ts569-journal-head-')), + filename = join(directory, 'head.sqlite'), + k = open(filename), + writer = open(filename) + let view: Knex.Transaction | undefined + try { + await k.raw('PRAGMA journal_mode=WAL') + await install(k) + await k('snapshot_journal_scope').insert({ + tableId: 3, + userId: 1, + id1: 1, + id2: 0, + exactText: '', + present: 1, + revision: '9007199254740993' + }) + view = await k.transaction() + expect(await readSnapshotJournalHighWater(view, 1, rev('0'), rev('0'))).toBe('9007199254740993') + await writer('snapshot_journal_scope').update({ revision: '9007199254740995' }) + expect(await readSnapshotJournalHighWater(view, 1, rev('0'), rev('0'))).toBe('9007199254740993') + expect(await readSnapshotJournalHighWater(writer, 1, rev('0'), rev('0'))).toBe('9007199254740995') + } finally { + await view?.rollback() + await k.destroy() + await writer.destroy() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.ts new file mode 100644 index 000000000..128527fe6 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.ts @@ -0,0 +1,48 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { + compareSnapshotJournalRevisions, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' +import { snapshotJournalRevisionText } from './SnapshotJournalRevisionSql' + +/** Fixed indexed head seeks in the pinned view. MySQL callers retain the exclusive writer barrier during pinning. */ +export async function readSnapshotJournalHighWater( + k: Knex, + userId: number, + minimum: SnapshotJournalRevision, + floor: SnapshotJournalRevision +): Promise { + const client = k.client.config.client, + local = client === 'sqlite3' || client === 'better-sqlite3' + if (!local && client !== 'mysql' && client !== 'mysql2') + throw new WERR_INVALID_OPERATION('Unsupported snapshot journal SQL driver') + if (!Number.isSafeInteger(userId) || userId < 1) throw new WERR_INVALID_OPERATION('Invalid snapshot journal profile') + snapshotJournalRevision(minimum) + snapshotJournalRevision(floor) + if (compareSnapshotJournalRevisions(minimum, floor) < 0) + throw new WERR_INVALID_OPERATION('Snapshot journal continuity was collected') + let high = minimum + const streams = [ + ...Array.from({ length: 13 }, (_, tableId) => ({ stream: 'scope', tableId })), + ...[8, 9].map(tableId => ({ stream: 'physical', tableId })) + ] + await runInSeries(streams, async ({ stream, tableId }) => { + const table = 'snapshot_journal_' + stream + const query = k + .from(k.raw(local ? '?? AS ?? INDEXED BY ??' : '?? AS ?? FORCE INDEX (??)', [table, 'j', table + '_page'])) + .where('j.tableId', tableId) + .select({ revisionText: snapshotJournalRevisionText(k, 'j.revision') }) + .orderBy('j.revision', 'desc') + .first() + if (stream === 'scope') query.where('j.userId', userId) + const row: { revisionText: unknown } | undefined = await query + if (row !== undefined) { + const value = snapshotJournalRevision(row.revisionText) + if (compareSnapshotJournalRevisions(value, high) > 0) high = value + } + }) + return high +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts new file mode 100644 index 000000000..6d56f08b2 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts @@ -0,0 +1,430 @@ +import { knex, type Knex } from 'knex' +import { copySnapshotJournalBootstrapPage, SNAPSHOT_JOURNAL_BOOTSTRAP_DDL } from './SnapshotJournalBootstrap' +import { installSnapshotJournalMysqlClock } from './SnapshotJournalMysqlClock' +import { SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL } from './SnapshotJournalSqliteObservers' +import { readSnapshotJournalMetadataPage } from './SnapshotJournalPage' +import { snapshotJournalRevision as rev } from './SnapshotJournalRevision' +import { numeric, legacyNames } from '../../schema/snapshotSqliteMembership' + +// Real Knex MySQL compilation/response processing with relational state and +// rollback in SQLite. Native MySQL fixtures independently prove DDL, locks, +// source observers, optimizer plans and process/server-crash behavior. +const identities = [ + ...numeric.map(source => ({ + table: source.table, + keys: [source.key], + text: undefined as string | undefined, + extra: undefined as string | undefined + })), + { + table: 'tx_labels_map', + keys: ['txLabelId', 'transactionId'], + text: undefined, + extra: undefined + }, + { + table: 'output_tags_map', + keys: ['outputTagId', 'outputId'], + text: undefined, + extra: undefined + }, + { + table: 'certificate_fields', + keys: ['fieldName', 'certificateId'], + text: 'fieldName', + extra: undefined + }, + { + table: legacyNames.profile, + keys: ['snapshotTableId', 'snapshotUserId', 'snapshotRowId'], + text: undefined, + extra: undefined + }, + { + table: legacyNames.relation, + keys: ['snapshotTableId', 'snapshotUserId', 'snapshotLeftId', 'snapshotRightId'], + text: undefined, + extra: 'snapshotMembership' + }, + { + table: legacyNames.certificate, + keys: ['snapshotUserId', 'snapshotFieldName', 'snapshotCertificateId'], + text: 'snapshotFieldName', + extra: 'snapshotMembership' + }, + { + table: legacyNames.keys, + keys: ['tableId', 'userId', 'rowId'], + text: undefined, + extra: 'present' + } +] +interface Query { + sql: string + bindings: Knex.RawBinding[] + method: string + response?: unknown +} +async function driver() { + const db = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }), + k = knex({ client: 'mysql2' }) + const state = { + next: 9007199254740993n, + last: '', + allocated: undefined as unknown, + filesort: false, + noIndex: false, + oversized: false, + failMetadata: false, + missingRevisionClock: false, + nonBinaryText: false + } + const queries: Query[] = [] + const connection = { + __knexUid: 'journal-driver', + query: ( + options: { sql: string }, + bindings: Knex.RawBinding[] | undefined, + callback: (error: unknown, rows?: unknown, fields?: unknown) => void + ) => { + void k.client._query(connection, { sql: options.sql, bindings: bindings ?? [], method: 'raw' }).then( + (result: { response: [unknown, unknown] }) => callback(null, ...result.response), + (error: unknown) => callback(error) + ) + } + } + k.client.acquireConnection = async () => connection + k.client.releaseConnection = async () => undefined + k.client._query = async (_connection: unknown, q: Query) => { + queries.push({ sql: q.sql, bindings: q.bindings, method: q.method }) + const respond = (value: unknown) => { + q.response = [value, []] + return q + } + if (q.sql.startsWith('SELECT INDEX_NAME name,COLUMN_NAME field')) { + const source = identities.find(source => source.table === q.bindings[0]) + if (!source) throw new Error('Unknown fixture index') + return respond(state.noIndex ? [] : source.keys.map(field => ({ name: 'PRIMARY', field }))) + } + if (q.sql === 'SELECT CAST(LAST_INSERT_ID() AS CHAR) value') + return respond([{ value: state.allocated === undefined ? state.last : state.allocated }]) + if (state.missingRevisionClock && q.sql.startsWith('select CAST(') && q.sql.includes('`snapshot_journal_clock`')) + return respond([]) + let sql = q.sql + .replace(/ FORCE INDEX \(`[^`]+`\)/g, '') + .replace(/ (?:for (?:share|update)(?: nowait)?|lock in share mode)$/i, '') + .replaceAll(' AS BINARY)', ' AS BLOB)') + .replaceAll(' AS SIGNED)', ' AS INTEGER)') + if (sql.startsWith('EXPLAIN ')) { + const plan: Array<{ detail: string }> = await db.raw('EXPLAIN QUERY PLAN ' + sql.slice(8), q.bindings) + return respond( + plan.map(row => ({ + Extra: state.filesort ? 'Using filesort' : row.detail.includes('TEMP B-TREE') ? 'Using filesort' : '' + })) + ) + } + if (sql.startsWith('CREATE TABLE ')) { + sql = sql + .replace(/ ENGINE=InnoDB$/, '') + .replace('revision BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY', 'revision TEXT NOT NULL PRIMARY KEY') + .replaceAll('BIGINT UNSIGNED', 'INTEGER') + } + if (/^insert into `snapshot_journal_events` \(\) values \(\)$/i.test(sql)) { + state.last = String(state.next++) + await db('snapshot_journal_events').insert({ revision: state.last }) + return respond({ insertId: state.last, affectedRows: 1 }) + } + sql = sql.replace(/^insert ignore into /i, 'insert or ignore into ') + if (sql.includes(' on duplicate key update ')) { + expect(sql).toMatch(/ on duplicate key update `id1` = `snapshot_journal_(?:physical|scope)`\.`id1`$/) + sql = sql.replace(/ on duplicate key update .+$/, ' on conflict do nothing') + if (state.failMetadata) throw new Error('metadata write failed') + } + const result = await db.raw(sql, q.bindings) + if (state.nonBinaryText && Array.isArray(result) && q.sql.includes('`boundedText`')) + for (const row of result) row.boundedText = 'not bytes' + if (Array.isArray(result)) + return respond( + state.oversized && sql.startsWith('select `s`.') ? Array.from({ length: 257 }, () => result[0]) : result + ) + return respond({ affectedRows: result?.changes ?? 0, insertId: result?.lastInsertRowid ?? 0 }) + } + await installSnapshotJournalMysqlClock(k, rev('9223372036854775807')) + await db.raw(SNAPSHOT_JOURNAL_BOOTSTRAP_DDL) + await db('snapshot_journal_bootstrap').insert({ id: 1, stream: 0, cursor: null }) + for (const ddl of SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL) await db.raw(ddl) + for (const source of identities) { + const columns = [...source.keys, ...(source.extra ? [source.extra] : [])].map( + name => '`' + name + '` ' + (source.text === name ? 'TEXT' : 'INTEGER') + ' NOT NULL' + ) + await db.raw( + 'CREATE TABLE `' + + source.table + + '`(' + + columns.join(',') + + ',PRIMARY KEY(' + + source.keys.map(key => '`' + key + '`').join(',') + + '))' + ) + } + queries.length = 0 + return { + db, + k, + state, + queries, + close: async () => { + await k.destroy() + await db.destroy() + } + } +} +function sample(stream: number, id: number) { + const source = identities[stream], + row: Record = {} + for (const key of source.keys) + row[key] = + key === source.text + ? 'field-' + String(id).padStart(3, '0') + : key.toLowerCase().includes('tableid') + ? 0 + : key.toLowerCase().includes('userid') + ? 1 + : id + if (source.extra) row[source.extra] = 1 + return row +} +test.each(identities.map((source, stream) => [source.table, stream] as const))( + 'MySQL bootstrap copies and resumes indexed %s keys with exact revisions', + async (_table, stream) => { + const f = await driver() + try { + const source = identities[stream] + await f.db('snapshot_journal_bootstrap').update({ stream, cursor: null }) + await f.db(source.table).insert([sample(stream, 1), sample(stream, 2)]) + const first = await copySnapshotJournalBootstrapPage(f.k) + expect(first).toEqual({ complete: false, selected: 2, stream, invalidated: false }) + const metadata = stream < 13 ? 'snapshot_journal_physical' : 'snapshot_journal_scope', + rows = await f.db(metadata).select('*', f.db.raw('CAST(revision AS TEXT) AS exactRevision')) + expect(rows).toHaveLength(2) + expect(rows.map(row => row.exactRevision)).toEqual(['9007199254740993', '9007199254740993']) + expect(rows.every(row => row.exactText instanceof Uint8Array)).toBe(true) + expect(await f.db('snapshot_journal_events')).toEqual([]) + await f.db(source.table).insert(sample(stream, 3)) + expect((await copySnapshotJournalBootstrapPage(f.k)).selected).toBe(1) + const final = await copySnapshotJournalBootstrapPage(f.k) + expect(final).toEqual({ complete: stream === 16, selected: 0, stream, invalidated: false }) + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: stream + 1, + cursor: null + }) + expect(f.queries.some(q => q.sql.endsWith('for update nowait'))).toBe(true) + expect( + f.queries.some(q => q.sql.includes('FORCE INDEX (`PRIMARY`)') && q.sql.endsWith('lock in share mode')) + ).toBe(true) + } finally { + await f.close() + } + } +) +test('MySQL bootstrap bounds each page and preserves an already observed newer generation', async () => { + const f = await driver() + try { + for (let first = 1; first <= 300; first += 50) + await f.db('transactions').insert(Array.from({ length: 50 }, (_, i) => ({ transactionId: first + i }))) + await f.db('snapshot_journal_physical').insert({ + tableId: 0, + id1: 1, + id2: 0, + exactText: Buffer.alloc(0), + revision: '9007199254740999', + generation: '9007199254740998', + present: 0 + }) + const page = await copySnapshotJournalBootstrapPage(f.k) + expect(page.selected).toBe(256) + expect(f.queries.filter(q => q.sql.startsWith('insert into `snapshot_journal_physical`'))).toHaveLength(4) + const preserved = await f + .db('snapshot_journal_physical') + .where({ tableId: 0, id1: 1 }) + .select('present', f.db.raw('CAST(revision AS TEXT) revision'), f.db.raw('CAST(generation AS TEXT) generation')) + .first() + expect(preserved).toEqual({ + present: 0, + revision: '9007199254740999', + generation: '9007199254740998' + }) + expect((await copySnapshotJournalBootstrapPage(f.k)).selected).toBe(44) + } finally { + await f.close() + } +}) +test.each([ + ['capacity-exhausted', '10', '11'], + ['revision-exhausted', '9223372036854775807', '9223372036854775808'] +] as const)('MySQL %s invalidates atomically without advancing bootstrap', async (reason, ceiling, next) => { + const f = await driver() + try { + await f.db('transactions').insert({ transactionId: 1 }) + await f.db('snapshot_journal_clock').update({ ceiling }) + f.state.next = BigInt(next) + expect(await copySnapshotJournalBootstrapPage(f.k)).toEqual({ + complete: false, + selected: 1, + stream: 0, + invalidated: true + }) + expect(await f.db('snapshot_journal_invalid')).toEqual([{ id: 1, reason }]) + expect(await f.db('snapshot_journal_physical')).toEqual([]) + expect((await f.db('snapshot_journal_bootstrap').first()).cursor).toBeNull() + expect(await f.db('transactions')).toEqual([{ transactionId: 1 }]) + expect((await copySnapshotJournalBootstrapPage(f.k)).invalidated).toBe(true) + } finally { + await f.close() + } +}) +test.each([null, 1, '-1', '1.5', 'bad'])('MySQL malformed allocator value %p rolls back page state', async value => { + const f = await driver() + try { + await f.db('transactions').insert({ transactionId: 1 }) + f.state.allocated = value + await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + expect(await f.db('snapshot_journal_events')).toEqual([]) + expect(await f.db('snapshot_journal_physical')).toEqual([]) + expect((await f.db('snapshot_journal_bootstrap').first()).cursor).toBeNull() + } finally { + await f.close() + } +}) +test.each(['filesort', 'noIndex', 'oversized', 'failMetadata'] as const)( + 'MySQL %s refuses and rolls back before checkpoint advancement', + async kind => { + const f = await driver() + try { + await f.db('transactions').insert({ transactionId: 1 }) + f.state[kind] = true + await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow() + expect(await f.db('snapshot_journal_physical')).toEqual([]) + expect((await f.db('snapshot_journal_bootstrap').first()).cursor).toBeNull() + } finally { + await f.close() + } + } +) +test('MySQL oversized historical UTF-8 key invalidates without copying or changing source', async () => { + const f = await driver() + try { + await f.db('snapshot_journal_bootstrap').update({ stream: 12 }) + await f.db('certificate_fields').insert({ fieldName: 'a'.repeat(401), certificateId: 1 }) + expect((await copySnapshotJournalBootstrapPage(f.k)).invalidated).toBe(true) + expect(await f.db('snapshot_journal_invalid')).toEqual([{ id: 1, reason: 'key-out-of-range' }]) + expect(await f.db('snapshot_journal_physical')).toEqual([]) + expect((await f.db('certificate_fields').first()).fieldName).toHaveLength(401) + } finally { + await f.close() + } +}) +test('MySQL page continuation follows the complete byte-exact composite cursor', async () => { + const f = await driver() + try { + const values = ['A', 'a', 'a ', 'é', '中'] + for (const [i, exactText] of values.entries()) + await f.db('snapshot_journal_scope').insert({ + tableId: 12, + userId: 1, + id1: i < 3 ? 1 : 2, + id2: 0, + exactText: Buffer.from(exactText), + revision: '9007199254740993', + present: 1 + }) + let after, + actual: string[] = [] + for (let i = 0; i < 4; i++) { + const page = await readSnapshotJournalMetadataPage(f.k, { + stream: 'scope', + tableId: 12, + userId: 1, + floor: rev('0'), + low: rev('9007199254740992'), + high: rev('9007199254740999'), + limit: 2, + after + }) + actual.push(...page.rows.map(row => row.exactText)) + if (page.complete) break + after = page.after + } + expect(actual).toEqual(values) + expect(f.queries.some(q => q.sql.includes(' or (') && q.sql.includes('`j`.`exactText` > ?'))).toBe(true) + } finally { + await f.close() + } +}) +test.each(['clock', 'bootstrap'])('missing MySQL %s state refuses rather than creating a new baseline', async kind => { + const f = await driver() + try { + await f.db('snapshot_journal_' + kind).delete() + await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + expect(await f.db('snapshot_journal_physical')).toEqual([]) + } finally { + await f.close() + } +}) +test.each(['not-json', '[]', '[-1]', '[1.5]', '["1"]', 'x'.repeat(2049)])( + 'invalid MySQL bootstrap cursor %s refuses without source changes', + async cursor => { + const f = await driver() + try { + await f.db('transactions').insert({ transactionId: 1 }) + await f.db('snapshot_journal_bootstrap').update({ cursor }) + await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + expect(await f.db('snapshot_journal_physical')).toEqual([]) + expect(await f.db('transactions')).toEqual([{ transactionId: 1 }]) + } finally { + await f.close() + } + } +) +test('completed MySQL bootstrap is stable and a dangling terminal cursor refuses', async () => { + const f = await driver() + try { + await f.db('snapshot_journal_bootstrap').update({ stream: 17 }) + expect(await copySnapshotJournalBootstrapPage(f.k)).toEqual({ + complete: true, + selected: 0, + stream: 17, + invalidated: false + }) + await f.db('snapshot_journal_bootstrap').update({ cursor: '[]' }) + await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + } finally { + await f.close() + } +}) + +test.each(['missingRevisionClock', 'nonBinaryText'] as const)( + 'malformed bootstrap driver %s rolls back progress', + async kind => { + const f = await driver() + try { + const stream = kind === 'nonBinaryText' ? 12 : 0 + await f.db(identities[stream].table).insert(sample(stream, 1)) + await f.db('snapshot_journal_bootstrap').update({ stream }) + const clock = await f.db('snapshot_journal_clock'), + progress = await f.db('snapshot_journal_bootstrap') + f.state[kind] = true + await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + expect(await f.db('snapshot_journal_physical')).toEqual([]) + expect(await f.db('snapshot_journal_clock')).toEqual(clock) + expect(await f.db('snapshot_journal_bootstrap')).toEqual(progress) + } finally { + await f.close() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.test.ts new file mode 100644 index 000000000..4cff09458 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.test.ts @@ -0,0 +1,36 @@ +import { knex } from 'knex' +import { installSnapshotJournalMysqlClock } from './SnapshotJournalMysqlClock' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' + +test('MySQL clock installation refuses SQLite before issuing SQL', async () => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + const query = jest.fn() + k.on('query', query) + try { + await expect(installSnapshotJournalMysqlClock(k, snapshotJournalRevision('1'))).rejects.toThrow( + 'Snapshot journal clock requires MySQL' + ) + expect(query).not.toHaveBeenCalled() + } finally { + await k.destroy() + } +}) + +test.each(['0', '-1', '01', '9223372036854775808'])( + 'MySQL clock refuses invalid event ceiling %s before DDL or acquisition', + async ceiling => { + const k = knex({ client: 'mysql2' }) + const query = jest.fn() + k.on('query', query) + try { + await expect(installSnapshotJournalMysqlClock(k, ceiling as SnapshotJournalRevision)).rejects.toThrow() + expect(query).not.toHaveBeenCalled() + } finally { + await k.destroy() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.ts new file mode 100644 index 000000000..721fbe641 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.ts @@ -0,0 +1,43 @@ +import type { Knex } from 'knex' +import { runInSeries } from '../../../utility/runInSeries' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { + MAX_SNAPSHOT_JOURNAL_REVISION, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' + +export const SNAPSHOT_JOURNAL_MYSQL_CLOCK_DDL = [ + 'CREATE TABLE snapshot_journal_clock(id INTEGER NOT NULL PRIMARY KEY,ceiling BIGINT UNSIGNED NOT NULL,CHECK(id=1)) ENGINE=InnoDB', + 'CREATE TABLE snapshot_journal_events(revision BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY) ENGINE=InnoDB', + "CREATE TABLE snapshot_journal_invalid(id INTEGER NOT NULL PRIMARY KEY,reason VARCHAR(32) NOT NULL,CHECK(id=1),CHECK(reason IN ('capacity-exhausted','revision-exhausted','key-out-of-range'))) ENGINE=InnoDB" +] + +export const SNAPSHOT_JOURNAL_MYSQL_CLOCK_VARIABLES = `DECLARE journalCeiling BIGINT UNSIGNED; +DECLARE journalRevision BIGINT UNSIGNED; +DECLARE journalEnabled BOOLEAN; ` + +/** Shared barrier; invalidation uses another row. Unexpected native errors roll back the source transaction. */ +export const SNAPSHOT_JOURNAL_MYSQL_ADVANCE = `SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE; +SELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled; +IF journalEnabled THEN + INSERT INTO snapshot_journal_events() VALUES(); + SET journalRevision=LAST_INSERT_ID(); + DELETE FROM snapshot_journal_events WHERE revision=journalRevision; + IF journalRevision>journalCeiling THEN + INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>${MAX_SNAPSHOT_JOURNAL_REVISION},'revision-exhausted','capacity-exhausted')); + SET journalEnabled=FALSE; + END IF; +END IF; ` + +/** Fresh owned fixture/installation only; registered partial-DDL recovery is separate. */ +export async function installSnapshotJournalMysqlClock(k: Knex, ceiling: SnapshotJournalRevision): Promise { + if (k.client.config.client !== 'mysql' && k.client.config.client !== 'mysql2') { + throw new WERR_INVALID_OPERATION('Snapshot journal clock requires MySQL') + } + if (snapshotJournalRevision(ceiling) === '0') throw new WERR_INVALID_OPERATION('Empty snapshot journal event window') + await runInSeries(SNAPSHOT_JOURNAL_MYSQL_CLOCK_DDL, async statement => { + await k.raw(statement) + }) + await k('snapshot_journal_clock').insert({ id: 1, ceiling }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts new file mode 100644 index 000000000..852cb6933 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts @@ -0,0 +1,736 @@ +import * as observers from './SnapshotJournalMysqlObservers' +import { knex, type Knex } from 'knex' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + completeSnapshotJournalMysqlGeneration, + installSnapshotJournalMysqlGeneration, + readSnapshotJournalMysqlGeneration +} from './SnapshotJournalMysqlGeneration' +import { readSnapshotJournalMysqlBinding } from './SnapshotJournalMysqlSource' +import { snapshotJournalRevision } from './SnapshotJournalRevision' +jest.mock('./SnapshotJournalMysqlSource', () => ({ readSnapshotJournalMysqlBinding: jest.fn() })) +interface Capture { + sql: string + bindings?: unknown[] + rows: Array> +} +const captured: Capture[] = JSON.parse( + readFileSync( + join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json'), + 'utf8' + ) +) +const nativeState = JSON.parse( + readFileSync(join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-generation-state-fixture.json'), 'utf8') +) as Record +const ceiling = snapshotJournalRevision('9223372036854775807') +const readBinding = jest.mocked(readSnapshotJournalMysqlBinding) +const nativeDdl: { epoch: string; ddl: Array<{ sql: string; bindings: unknown[] }> } = JSON.parse( + readFileSync(join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json'), 'utf8') +) +async function fixture() { + const database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + await database.raw( + 'CREATE TABLE snapshot_journal_generation(id INTEGER,version INTEGER,epoch TEXT,source TEXT,plan TEXT,ceiling TEXT,nextObject INTEGER,complete INTEGER)' + ) + await database('snapshot_journal_generation').insert(nativeState) + await database.raw('CREATE TABLE snapshot_journal_clock(id INTEGER,ceiling TEXT)') + await database('snapshot_journal_clock').insert({ id: 1, ceiling }) + await database.raw('CREATE TABLE snapshot_journal_bootstrap(id INTEGER,stream INTEGER,cursor TEXT)') + await database('snapshot_journal_bootstrap').insert({ id: 1, stream: 17, cursor: null }) + await database.raw('CREATE TABLE snapshot_journal_invalid(id INTEGER,reason TEXT)') + await database.raw('CREATE TABLE snapshot_journal_events(revision TEXT)') + const metadata = structuredClone(captured), + writes: string[] = [] + database.on('query', query => { + if (/^(?:insert|update|delete|create|alter|drop)/i.test(query.sql)) writes.push(query.sql) + }) + readBinding.mockResolvedValue(String(nativeState.source)) + let activeEpoch = String(nativeState.epoch), + available: Set | undefined + const fault = { + phase: '', + fired: false, + zeroNext: false, + zeroComplete: false, + beforeTransaction: undefined as undefined | ((t: Knex) => Promise) + } + const fail = (phase: string): void => { + if (fault.phase === phase && !fault.fired) { + fault.fired = true + throw new Error('lost reply at ' + phase) + } + } + const raw = jest.fn((sql: string, values?: unknown[]) => { + if (sql === 'CAST(ceiling AS CHAR) ceiling') return database.raw(sql) + if (sql === 'SELECT VERSION() version') return Promise.resolve([[{ version: '8.4.0' }]]) + const name = /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] + if (name) + return (async () => { + fail('before:' + name) + const reference = nativeDdl.ddl.find( + entry => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(entry.sql)?.[1] === name + ) + if (!reference || !available || available.has(name)) throw new Error('Unowned fixture DDL') + if (name === 'snapshot_journal_generation') activeEpoch = String(values?.[0]) + expect(sql).toBe(reference.sql.replaceAll(nativeDdl.epoch, activeEpoch)) + expect(values ?? []).toEqual( + name === 'snapshot_journal_generation' + ? [activeEpoch, nativeState.source, nativeState.plan, ceiling] + : reference.bindings + ) + if (name === 'snapshot_journal_generation') + await database(name).insert({ + id: 1, + version: 1, + epoch: activeEpoch, + source: values![1], + plan: values![2], + ceiling: values![3], + nextObject: 0, + complete: 0 + }) + if (name === 'snapshot_journal_clock') await database(name).insert({ id: 1, ceiling: values![0] }) + if (name === 'snapshot_journal_bootstrap') await database(name).insert({ id: 1, stream: 0, cursor: null }) + available.add(name) + fail('after:' + name) + return {} + })() + const found = metadata.find( + entry => entry.sql === sql && JSON.stringify(entry.bindings ?? []) === JSON.stringify(values ?? []) + ) + if (!found) throw new Error('Unexpected native metadata request: ' + sql) + let result = JSON.parse(JSON.stringify(found.rows).replaceAll(String(nativeState.epoch), activeEpoch)) as Array< + Record + > + if (available) { + if (sql.includes('LOWER(LEFT(')) result = result.filter(row => available!.has(String(row.name))) + else { + const owned = values?.find(value => typeof value === 'string' && value.startsWith('snapshot_journal_')) + if (typeof owned === 'string' && !available.has(owned)) result = [] + } + } + return Promise.resolve([result]) + }) + const wrap = (connection: Knex, isTransaction = false): Knex => + Object.assign( + (table: string) => { + const builder = connection(table) + // Only the native fixture establishes MySQL row-lock behavior. This fixture + // executes state DML/rollback in SQLite and owns exact native DDL/metadata. + builder.forUpdate = () => builder + builder.noWait = () => builder + const update = builder.update.bind(builder) + builder.update = ((patch: Record) => + (async () => { + if ('nextObject' in patch && fault.zeroNext) return 0 + if ('complete' in patch && fault.zeroComplete) return 0 + if ('complete' in patch) fail('complete:before') + const changed = await update(patch) + if ('nextObject' in patch) fail('ack:' + String(patch.nextObject)) + if ('complete' in patch) fail('complete:after') + return changed + })()) as typeof builder.update + return builder + }, + { + client: { config: { client: 'mysql2' } }, + isTransaction, + raw, + transaction: async (callback: (t: Knex) => Promise) => + await connection.transaction(async t => { + await fault.beforeTransaction?.(t) + return await callback(wrap(t, true)) + }) + } + ) as unknown as Knex + const k = wrap(database) + const fresh = async (): Promise => { + for (const table of [ + 'snapshot_journal_generation', + 'snapshot_journal_clock', + 'snapshot_journal_bootstrap', + 'snapshot_journal_invalid', + 'snapshot_journal_events' + ]) + await database(table).delete() + available = new Set() + writes.length = 0 + } + const rows = (fragment: string, table?: string): Array> => { + const entry = metadata.find( + entry => entry.sql.includes(fragment) && (table === undefined || entry.bindings?.includes(table)) + ) + if (!entry) throw new Error('Missing native fixture metadata: ' + fragment) + return entry.rows + } + return { database, k, metadata, rows, writes, raw, fresh, fault } +} +test('native metadata resumes the complete persisted generation without writes', async () => { + const f = await fixture() + try { + expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling)).toEqual({ + source: nativeState.source, + plan: nativeState.plan, + ceiling, + epoch: nativeState.epoch, + nextObject: 57, + complete: true, + enabled: true + }) + expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling)).toEqual( + await readSnapshotJournalMysqlGeneration(f.k, ceiling) + ) + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) +test('lost final object acknowledgement resumes its epoch and advances only the durable intent', async () => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update({ nextObject: 56, complete: 0 }) + await f.database('snapshot_journal_bootstrap').update({ stream: 0 }) + f.writes.length = 0 + const resumed = await installSnapshotJournalMysqlGeneration(f.k, ceiling) + expect(resumed).toMatchObject({ + epoch: nativeState.epoch, + nextObject: 57, + complete: false, + enabled: true + }) + expect(f.writes).toHaveLength(1) + expect(f.writes[0]).toMatch(/^update `snapshot_journal_generation`/) + } finally { + await f.database.destroy() + } +}) +test.each(['sqlite3', 'better-sqlite3', 'pg'])('unsupported driver %s performs no I/O', async client => { + const f = await fixture() + try { + f.k.client.config.client = client + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.raw).not.toHaveBeenCalled() + } finally { + await f.database.destroy() + } +}) +test('DDL refuses a caller-owned transaction', async () => { + const f = await fixture() + try { + Object.defineProperty(f.k, 'isTransaction', { value: true }) + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.raw).not.toHaveBeenCalled() + } finally { + await f.database.destroy() + } +}) +test('generation reads accept the caller pinned view without DDL', async () => { + const f = await fixture() + try { + Object.defineProperty(f.k, 'isTransaction', { value: true }) + expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + epoch: nativeState.epoch, + complete: true, + enabled: true + }) + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) +test('persisted allocator rows cannot establish event continuity', async () => { + const f = await fixture() + try { + await f.database('snapshot_journal_events').insert({ revision: '1' }) + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) +test.each(['0', '01', '9223372036854775808'])('invalid ceiling %s refuses before metadata', async value => { + const f = await fixture() + try { + await expect(installSnapshotJournalMysqlGeneration(f.k, value as typeof ceiling)).rejects.toThrow() + expect(f.raw).not.toHaveBeenCalled() + } finally { + await f.database.destroy() + } +}) +test.each([ + ['TABLE_COMMENT comment', 'engine', 'MyISAM'], + ['TABLE_COMMENT comment', 'type', 'VIEW'], + ['TABLE_COMMENT comment', 'collation', 'utf8mb4_general_ci'], + ['TABLE_COMMENT comment', 'rowFormat', 'Compact'], + ['TABLE_COMMENT comment', 'options', ''], + ['TABLE_COMMENT comment', 'comment', 'snapshot-journal-owner:foreign'], + ['ORDINAL_POSITION LIMIT 9', 'name', 'foreign'], + ['ORDINAL_POSITION LIMIT 9', 'type', 'bigint'], + ['ORDINAL_POSITION LIMIT 9', 'nullable', 'YES'], + ['ORDINAL_POSITION LIMIT 9', 'defaultValue', 0], + ['ORDINAL_POSITION LIMIT 9', 'extra', 'auto_increment'], + ['ORDINAL_POSITION LIMIT 9', 'charset', 'utf8mb4'], + ['ORDINAL_POSITION LIMIT 9', 'collation', 'utf8mb4_bin'], + ['ORDINAL_POSITION LIMIT 9', 'expression', '1'], + ['SEQ_IN_INDEX LIMIT 16', 'name', 'foreign'], + ['SEQ_IN_INDEX LIMIT 16', 'columnName', 'ceiling'], + ['SEQ_IN_INDEX LIMIT 16', 'position', 2], + ['SEQ_IN_INDEX LIMIT 16', 'nonUnique', 1], + ['SEQ_IN_INDEX LIMIT 16', 'direction', 'D'], + ['SEQ_IN_INDEX LIMIT 16', 'prefix', 1], + ['SEQ_IN_INDEX LIMIT 16', 'type', 'HASH'], + ['SEQ_IN_INDEX LIMIT 16', 'visible', 'NO'], + ['SEQ_IN_INDEX LIMIT 16', 'expression', '1'], + ['CONSTRAINT_NAME LIMIT 8', 'name', 'foreign'], + ['CONSTRAINT_NAME LIMIT 8', 'type', 'CHECK'], + ['CONSTRAINT_NAME LIMIT 8', 'enforced', 'NO'], + ['CONSTRAINT_NAME LIMIT 8', 'clause', '(`id` = 2)'] +])('table metadata %s %s drift refuses before mutation', async (fragment, field, value) => { + const f = await fixture() + try { + f.rows(fragment as string, 'snapshot_journal_clock')[0][field as string] = value + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) +test.each(['TABLE_COMMENT comment', 'ORDINAL_POSITION LIMIT 9', 'SEQ_IN_INDEX LIMIT 16', 'CONSTRAINT_NAME LIMIT 8'])( + 'missing %s metadata refuses', + async fragment => { + const f = await fixture() + try { + f.rows(fragment, 'snapshot_journal_clock').pop() + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } + } +) +test.each(['EVENT_OBJECT_TABLE=? LIMIT 1', 'PARTITION_NAME IS NOT NULL LIMIT 1', 'REFERENCED_TABLE_NAME=? LIMIT 1'])( + 'unowned observer/partition/foreign dependency %s refuses', + async fragment => { + const f = await fixture() + try { + f.rows(fragment, 'snapshot_journal_clock').push({ name: 'foreign' }) + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } + } +) +test.each([ + ['name', 'foreign'], + ['table', 'transactions'], + ['event', 'DELETE'], + ['timing', 'BEFORE'], + ['body', 'BEGIN DO 0; END'], + ['sqlMode', ''], + ['charset', 'ascii'], + ['collation', 'ascii_bin'], + ['databaseCollation', 'ascii_bin'], + ['definer', 'foreign@localhost'] +])('trigger %s drift refuses exact ownership', async (field, value) => { + const f = await fixture() + try { + f.rows('SUBSTRING(ACTION_STATEMENT,1,?)', 'snapshot_journal_scope_0_INSERT')[0][field] = value + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) +test.each(['unknown', 'view', 'missing-prior', 'duplicate', 'future', 'many'])( + 'reserved object %s cannot establish continuity', + async kind => { + const f = await fixture() + try { + const rows = f.rows('LOWER(LEFT(TABLE_NAME,17))') + if (kind === 'unknown') rows.push({ name: 'snapshot_journal_foreign', type: 'BASE TABLE' }) + if (kind === 'view') rows.find(row => row.name === 'snapshot_journal_clock')!.type = 'VIEW' + if (kind === 'missing-prior') + rows.splice( + rows.findIndex(row => row.name === 'snapshot_journal_clock'), + 1 + ) + if (kind === 'duplicate') rows.push({ name: 'snapshot_journal_clock', type: 'BASE TABLE' }) + if (kind === 'future') await f.database('snapshot_journal_generation').update({ nextObject: 0, complete: 0 }) + if (kind === 'many') + rows.push( + ...Array.from({ length: 130 }, (_, i) => ({ + name: 'snapshot_journal_foreign_' + i, + type: 'BASE TABLE' + })) + ) + f.writes.length = 0 + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } + } +) +test.each([ + { nextObject: 58 }, + { nextObject: 56 }, + { source: 'a'.repeat(64) }, + { plan: 'a'.repeat(64) }, + { ceiling: '1' } +])('intent drift %j refuses', async patch => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update(patch) + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) +test.each([ + { id: 2 }, + { stream: -1 }, + { stream: 18 }, + { stream: 0.5 }, + { stream: 16 }, + { cursor: '[]' }, + { cursor: 'x'.repeat(2049) } +])('completed bootstrap drift %j refuses', async patch => { + const f = await fixture() + try { + await f.database('snapshot_journal_bootstrap').update(patch) + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) +test.each(['snapshot_journal_clock', 'snapshot_journal_bootstrap'])( + 'missing %s seed cannot resume or publish', + async table => { + const f = await fixture() + try { + await f.database(table).delete() + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } + } +) +test.each(['capacity-exhausted', 'revision-exhausted', 'key-out-of-range'])( + 'valid invalidation %s reports disabled while preserving source state', + async reason => { + const f = await fixture() + try { + await f.database('snapshot_journal_invalid').insert({ id: 1, reason }) + expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + complete: true, + enabled: false + }) + } finally { + await f.database.destroy() + } + } +) +test.each([ + { id: 2, reason: 'capacity-exhausted' }, + { id: 1, reason: 'unknown' } +])('malformed invalidation %j refuses', async row => { + const f = await fixture() + try { + await f.database('snapshot_journal_invalid').insert(row) + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) + +const objectNames = nativeDdl.ddl.map( + entry => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(entry.sql)![1] +) +test('fresh installation uses the independently captured native DDL and atomically seeded controls', async () => { + const f = await fixture() + try { + await f.fresh() + const state = await installSnapshotJournalMysqlGeneration(f.k, ceiling) + expect(state).toMatchObject({ nextObject: 57, complete: false, enabled: true }) + expect(state.epoch).not.toBe(nativeState.epoch) + expect(await f.database('snapshot_journal_clock')).toEqual([{ id: 1, ceiling }]) + expect(await f.database('snapshot_journal_bootstrap')).toEqual([{ id: 1, stream: 0, cursor: null }]) + expect(objectNames).toHaveLength(58) + } finally { + await f.database.destroy() + } +}) +test.each(objectNames)('every DDL acknowledgement loss resumes without adopting/replacing %s', async name => { + const f = await fixture() + try { + await f.fresh() + f.fault.phase = 'after:' + name + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('lost reply') + const before = await f.database('snapshot_journal_generation').first() + const resumed = await installSnapshotJournalMysqlGeneration(f.k, ceiling) + expect(f.fault.fired).toBe(true) + expect(resumed).toMatchObject({ + epoch: before.epoch, + nextObject: 57, + complete: false, + enabled: true + }) + expect( + f.raw.mock.calls.filter( + ([sql]) => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] === name + ) + ).toHaveLength(1) + } finally { + await f.database.destroy() + } +}) +test.each([0, 1, 5, 6, 57])('before DDL boundary %i creates no undocumented object', async index => { + const f = await fixture() + try { + await f.fresh() + f.fault.phase = 'before:' + objectNames[index] + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('lost reply') + expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + nextObject: 57, + complete: false, + enabled: true + }) + } finally { + await f.database.destroy() + } +}) +test.each([1, 6, 7, 57])('lost progress acknowledgement %i resumes committed state', async position => { + const f = await fixture() + try { + await f.fresh() + f.fault.phase = 'ack:' + position + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('lost reply') + expect((await f.database('snapshot_journal_generation').first()).nextObject).toBe(position) + expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + nextObject: 57, + complete: false, + enabled: true + }) + } finally { + await f.database.destroy() + } +}) +test('completion commits once and can be read in the same retained generation', async () => { + const f = await fixture() + try { + expect(await completeSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + epoch: nativeState.epoch, + nextObject: 57, + complete: true, + enabled: true + }) + expect((await f.database('snapshot_journal_generation').first()).complete).toBe(1) + } finally { + await f.database.destroy() + } +}) +test.each(['complete:before', 'complete:after'])( + 'completion %s loss rolls back its durable publication', + async phase => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update({ complete: 0 }) + f.fault.phase = phase + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('lost reply') + expect((await f.database('snapshot_journal_generation').first()).complete).toBe(0) + expect(await completeSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + complete: true, + enabled: true + }) + } finally { + await f.database.destroy() + } + } +) +test.each(['incomplete', 'invalidated'])('completion refuses %s progress without publication', async kind => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update({ complete: 0 }) + if (kind === 'incomplete') await f.database('snapshot_journal_bootstrap').update({ stream: 16 }) + else await f.database('snapshot_journal_invalid').insert({ id: 1, reason: 'capacity-exhausted' }) + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect((await f.database('snapshot_journal_generation').first()).complete).toBe(0) + } finally { + await f.database.destroy() + } +}) + +// Context is part of persisted trigger ownership and the plan digest. A malformed +// driver response must not establish a weaker ownership contract. +test.each(['missing row', 'extra row', 'missing field', 'extra field', 'numeric field', 'oversized field'])( + 'installation rejects malformed native context: %s', + async kind => { + const f = await fixture() + try { + const rows = f.rows('SELECT @@sql_mode') + if (kind === 'missing row') rows.length = 0 + if (kind === 'extra row') rows.push({ ...rows[0] }) + if (kind === 'missing field') delete rows[0].definer + if (kind === 'extra field') rows[0].unexpected = 'unknown' + if (kind === 'numeric field') rows[0].charset = 1 + if (kind === 'oversized field') rows[0].sqlMode = 'x'.repeat(4097) + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } + } +) +test.each(['malformed trigger', 'missing trigger', 'duplicate trigger'])( + 'installation rejects an inconsistent generated plan: %s', + async kind => { + const f = await fixture() + let spy: jest.SpyInstance | undefined + try { + const definitions = await observers.snapshotJournalMysqlObserverSql(f.k) + if (kind === 'malformed trigger') definitions[0] = 'CREATE TABLE unrelated(id INT)' + if (kind === 'missing trigger') definitions.pop() + if (kind === 'duplicate trigger') definitions[1] = definitions[0] + spy = jest.spyOn(observers, 'snapshotJournalMysqlObserverSql').mockResolvedValue(definitions) + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.writes).toEqual([]) + } finally { + spy?.mockRestore() + await f.database.destroy() + } + } +) +test('zero journal capacity refuses before metadata reads or writes', async () => { + const f = await fixture() + try { + await expect(installSnapshotJournalMysqlGeneration(f.k, snapshotJournalRevision('0'))).rejects.toThrow( + 'Invalid or unowned' + ) + expect(f.raw).not.toHaveBeenCalled() + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) + +test('installation refuses a changed source binding before returning generation ownership', async () => { + const f = await fixture() + try { + readBinding.mockResolvedValueOnce(String(nativeState.source)).mockResolvedValueOnce('f'.repeat(64)) + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) +test('lost update ownership cannot acknowledge an installation object', async () => { + const f = await fixture() + try { + await f.fresh() + f.fault.zeroNext = true + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect((await f.database('snapshot_journal_generation').first()).nextObject).toBe(0) + f.fault.zeroNext = false + expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + nextObject: 57, + complete: false + }) + } finally { + await f.database.destroy() + } +}) +test('lost update ownership cannot publish completion', async () => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update({ complete: 0 }) + f.fault.zeroComplete = true + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect((await f.database('snapshot_journal_generation').first()).complete).toBe(0) + } finally { + await f.database.destroy() + } +}) +test.each(['clock missing', 'epoch changed', 'progress changed', 'bootstrap missing', 'invalidated'])( + 'publication revalidates transaction-owned %s and rolls back refusal', + async kind => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update({ complete: 0 }) + f.fault.beforeTransaction = async t => { + if (kind === 'clock missing') await t('snapshot_journal_clock').delete() + if (kind === 'epoch changed') + await t('snapshot_journal_generation').update({ + epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + if (kind === 'progress changed') await t('snapshot_journal_generation').update({ nextObject: 56 }) + if (kind === 'bootstrap missing') await t('snapshot_journal_bootstrap').delete() + if (kind === 'invalidated') await t('snapshot_journal_invalid').insert({ id: 1, reason: 'capacity-exhausted' }) + } + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect((await f.database('snapshot_journal_generation').first()).complete).toBe(0) + expect(await f.database('snapshot_journal_clock')).toEqual([{ id: 1, ceiling }]) + expect(await f.database('snapshot_journal_invalid')).toEqual([]) + } finally { + await f.database.destroy() + } + } +) +test('installation refuses a partial bootstrap cursor before resuming DDL', async () => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update({ nextObject: 56, complete: 0 }) + await f.database('snapshot_journal_bootstrap').update({ stream: 1 }) + f.writes.length = 0 + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) +test.each([58, 56])('installation refuses invalid complete next-object position %i', async nextObject => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update({ nextObject }) + f.writes.length = 0 + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) +test('generation requires its intent in the reserved-object inventory', async () => { + const f = await fixture() + try { + const rows = f.rows('LOWER(LEFT(TABLE_NAME,17))') + rows.splice( + rows.findIndex(row => row.name === 'snapshot_journal_generation'), + 1 + ) + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) + +test('final state rechecks a clock response after object validation', async () => { + const f = await fixture() + let reads = 0 + const listener = (rows: unknown[], query: { sql: string }) => { + if (query.sql.startsWith('select `id`, CAST(ceiling AS CHAR) ceiling from `snapshot_journal_clock`')) { + reads++ + if (reads === 2) rows.splice(0) + } + } + f.database.on('query-response', listener) + try { + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(reads).toBe(2) + } finally { + f.database.off('query-response', listener) + await f.database.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts new file mode 100644 index 000000000..4cb40f0f2 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts @@ -0,0 +1,493 @@ +import type { Knex } from 'knex' +import { createHash } from 'node:crypto' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { SNAPSHOT_JOURNAL_MYSQL_CLOCK_DDL } from './SnapshotJournalMysqlClock' +import { SNAPSHOT_JOURNAL_MYSQL_METADATA_DDL, snapshotJournalMysqlObserverSql } from './SnapshotJournalMysqlObservers' +import { SNAPSHOT_JOURNAL_BOOTSTRAP_DDL } from './SnapshotJournalBootstrap' +import { readSnapshotJournalMysqlBinding } from './SnapshotJournalMysqlSource' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' +import { + createSnapshotJournalMysqlIntent, + readSnapshotJournalMysqlIntent, + SNAPSHOT_JOURNAL_MYSQL_INTENT, + type SnapshotJournalMysqlIntent, + type SnapshotJournalMysqlBinding +} from './SnapshotJournalMysqlIntent' + +interface Column { + name: string + type: string + nullable?: boolean + auto?: boolean +} +interface Index { + name: string + columns: string[] + unique: boolean +} +interface Table { + name: string + sql: string + columns: Column[] + indexes: Index[] + checks: string[] + seed?: 'clock' | 'bootstrap' +} +interface Trigger { + name: string + sql: string + table: string + event: string + body: string +} +type ObjectDefinition = { type: 'table'; definition: Table } | { type: 'trigger'; definition: Trigger } +interface Context { + sqlMode: string + charset: string + collation: string + databaseCollation: string + definer: string +} +interface Plan { + binding: SnapshotJournalMysqlBinding + objects: ObjectDefinition[] + context: Context +} +export interface SnapshotJournalMysqlGeneration extends SnapshotJournalMysqlIntent { + enabled: boolean +} + +function invalid(): never { + throw new WERR_INVALID_OPERATION('Invalid or unowned MySQL snapshot journal generation') +} +function client(k: Knex): void { + if (!['mysql', 'mysql2'].includes(k.client.config.client)) invalid() +} +const primary = (...columns: string[]): Index => ({ name: 'PRIMARY', columns, unique: true }) +const column = (name: string, type: string): Column => ({ name, type }) +const integer = (name: string): Column => column(name, 'int') +const big = (name: string): Column => column(name, 'bigint unsigned') +const physicalKey = ['tableId', 'id1', 'id2', 'exactText'] +const physicalColumns = [integer('tableId'), big('id1'), big('id2'), column('exactText', 'varbinary(400)')] +const metadataColumns = [big('revision'), column('present', 'tinyint')] +const tail = ' ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC' + +function tables(): Table[] { + const ddl = [ + ...SNAPSHOT_JOURNAL_MYSQL_CLOCK_DDL, + ...SNAPSHOT_JOURNAL_MYSQL_METADATA_DDL, + SNAPSHOT_JOURNAL_BOOTSTRAP_DDL + ] + const specs: Omit[] = [ + { + name: 'snapshot_journal_clock', + columns: [integer('id'), big('ceiling')], + indexes: [primary('id')], + checks: ['(`id` = 1)'], + seed: 'clock' + }, + { + name: 'snapshot_journal_events', + columns: [{ ...big('revision'), auto: true }], + indexes: [primary('revision')], + checks: [] + }, + { + name: 'snapshot_journal_invalid', + columns: [integer('id'), column('reason', 'varchar(32)')], + indexes: [primary('id')], + checks: [ + '(`id` = 1)', + "(`reason` in (_utf8mb4'capacity-exhausted',_utf8mb4'revision-exhausted',_utf8mb4'key-out-of-range'))" + ] + }, + { + name: 'snapshot_journal_physical', + columns: [...physicalColumns, big('revision'), big('generation'), column('present', 'tinyint')], + indexes: [ + primary(...physicalKey), + { + name: 'snapshot_journal_physical_page', + columns: ['tableId', 'revision', 'id1', 'id2', 'exactText'], + unique: false + } + ], + checks: [] + }, + { + name: 'snapshot_journal_scope', + columns: [integer('tableId'), big('userId'), ...physicalColumns.slice(1), ...metadataColumns], + indexes: [ + primary('tableId', 'userId', 'id1', 'id2', 'exactText'), + { + name: 'snapshot_journal_scope_page', + columns: ['userId', 'tableId', 'revision', 'id1', 'id2', 'exactText'], + unique: false + } + ], + checks: [] + }, + { + name: 'snapshot_journal_bootstrap', + columns: [integer('id'), integer('stream'), { ...column('cursor', 'text'), nullable: true }], + indexes: [primary('id')], + checks: ['(`id` = 1)', '(`stream` between 0 and 17)'], + seed: 'bootstrap' + } + ] + return specs.map((spec, i) => ({ ...spec, sql: ddl[i].replace(/ ENGINE=InnoDB$/, '') + tail })) +} + +async function plan(k: Knex, ceiling: SnapshotJournalRevision, config?: Knex.MigratorConfig): Promise { + client(k) + if (snapshotJournalRevision(ceiling) === '0') return invalid() + const source = await readSnapshotJournalMysqlBinding(k, config) + const [contexts]: Context[][] = await k.raw( + 'SELECT @@sql_mode sqlMode,@@character_set_client charset,@@collation_connection collation,@@collation_database databaseCollation,CURRENT_USER() definer' + ) + const context = contexts[0] + const contextKeys: Array = ['sqlMode', 'charset', 'collation', 'databaseCollation', 'definer'] + if ( + contexts.length !== 1 || + context === null || + typeof context !== 'object' || + Array.isArray(context) || + Object.keys(context).length !== contextKeys.length || + contextKeys.some(key => typeof context[key] !== 'string' || context[key].length > 4096) + ) + return invalid() + const triggers = (await snapshotJournalMysqlObserverSql(k)).map(sql => { + const match = + /^CREATE TRIGGER (snapshot_journal_[A-Za-z0-9_]+) AFTER (INSERT|UPDATE|DELETE) ON `([a-z_]+)` FOR EACH ROW (BEGIN .*)$/s.exec( + sql + ) + if (!match) return invalid() + return { name: match[1], event: match[2], table: match[3], body: match[4], sql } + }) + const objects: ObjectDefinition[] = [ + ...tables().map(definition => ({ type: 'table' as const, definition })), + ...triggers.map(definition => ({ type: 'trigger' as const, definition })) + ] + if (objects.length !== 57 || new Set(objects.map(object => object.definition.name)).size !== objects.length) + return invalid() + const digest = createHash('sha256') + .update('snapshot-journal-mysql-plan-v1\n') + .update(JSON.stringify([objects, context])) + .digest('hex') + return { binding: { source, ceiling, plan: digest }, objects, context } +} + +async function reserved(k: Knex): Promise> { + const [tables]: Array> = await k.raw( + "SELECT TABLE_NAME name,TABLE_TYPE type FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LOWER(LEFT(TABLE_NAME,17))='snapshot_journal_' ORDER BY TABLE_NAME LIMIT 130" + ) + const [triggers]: Array> = await k.raw( + "SELECT TRIGGER_NAME name,'TRIGGER' type FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND LOWER(LEFT(TRIGGER_NAME,17))='snapshot_journal_' ORDER BY TRIGGER_NAME LIMIT 130" + ) + if (tables.length + triggers.length > 58) return invalid() + return [...tables, ...triggers] +} +const owner = (epoch: string): string => 'snapshot-journal-owner:' + epoch +function triggerBody(trigger: Trigger, epoch: string): string { + return trigger.body.replace(/^BEGIN /, 'BEGIN /* ' + owner(epoch) + ' */ ') +} + +async function validateTable(k: Knex, table: Table, epoch: string): Promise { + const [actual]: Array< + Array<{ + engine: string + type: string + collation: string + rowFormat: string + options: string + comment: string + }> + > = await k.raw( + 'SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [table.name] + ) + if ( + actual.length !== 1 || + actual[0].engine !== 'InnoDB' || + actual[0].type !== 'BASE TABLE' || + actual[0].collation !== 'utf8mb4_bin' || + actual[0].rowFormat !== 'Dynamic' || + actual[0].options !== 'row_format=DYNAMIC' || + actual[0].comment !== owner(epoch) + ) + return invalid() + const [columns]: Array< + Array<{ + name: string + type: string + nullable: string + defaultValue: unknown + extra: string + charset: string | null + collation: string | null + expression: string + }> + > = await k.raw( + 'SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9', + [table.name] + ) + if ( + columns.length !== table.columns.length || + columns.some((actual, i) => { + const expected = table.columns[i], + text = expected.type === 'text' || expected.type.startsWith('varchar') + return ( + actual.name !== expected.name || + actual.type.replace(/^(int|tinyint|bigint)\(\d+\)/, '$1') !== expected.type || + actual.nullable !== (expected.nullable ? 'YES' : 'NO') || + actual.defaultValue !== null || + actual.extra !== (expected.auto ? 'auto_increment' : '') || + actual.charset !== (text ? 'utf8mb4' : null) || + actual.collation !== (text ? 'utf8mb4_bin' : null) || + actual.expression !== '' + ) + }) + ) + return invalid() + const expectedIndexes = table.indexes.flatMap(index => + index.columns.map((columnName, i) => ({ + name: index.name, + columnName, + position: i + 1, + nonUnique: index.unique ? 0 : 1, + direction: 'A', + prefix: null, + type: 'BTREE', + visible: 'YES', + expression: null + })) + ) + const [indexes]: Array>> = await k.raw( + 'SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16', + [table.name] + ) + if (JSON.stringify(indexes) !== JSON.stringify(expectedIndexes)) return invalid() + const [constraints]: Array> = + await k.raw( + 'SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8', + [table.name] + ) + const expectedConstraints = [ + { name: 'PRIMARY', type: 'PRIMARY KEY', enforced: 'YES', clause: null }, + ...table.checks.map((clause, i) => ({ + name: table.name + '_chk_' + (i + 1), + type: 'CHECK', + enforced: 'YES', + clause: clause.replaceAll("'", "\\'") + })) + ] + if (JSON.stringify(constraints) !== JSON.stringify(expectedConstraints)) return invalid() + const [triggers]: unknown[][] = await k.raw( + 'SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1', + [table.name] + ) + const [partitions]: unknown[][] = await k.raw( + 'SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1', + [table.name] + ) + const [foreign]: unknown[][] = await k.raw( + 'SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1', + [table.name] + ) + if (triggers.length || partitions.length || foreign.length) return invalid() +} + +async function validateObject( + k: Knex, + object: ObjectDefinition, + state: SnapshotJournalMysqlIntent, + context: Context +): Promise { + if (object.type === 'table') { + await validateTable(k, object.definition, state.epoch) + if (object.definition.seed === 'clock') { + const clocks = await k('snapshot_journal_clock').select('id', k.raw('CAST(ceiling AS CHAR) ceiling')).limit(2) + if (clocks.length !== 1 || clocks[0].id !== 1 || clocks[0].ceiling !== state.ceiling) return invalid() + } + if (object.definition.seed === 'bootstrap' && state.nextObject < 57) { + const progress = await k('snapshot_journal_bootstrap').select('*').limit(2) + if (progress.length !== 1 || progress[0].id !== 1 || progress[0].stream !== 0 || progress[0].cursor !== null) + return invalid() + } + return + } + const definition = object.definition, + expectedBody = triggerBody(definition, state.epoch) + const [rows]: Array< + Array<{ + name: string + table: string + event: string + timing: string + body: string + sqlMode: string + charset: string + collation: string + databaseCollation: string + definer: string + }> + > = await k.raw( + 'SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?', + [expectedBody.length + 1, definition.name] + ) + if ( + rows.length !== 1 || + rows[0].name !== definition.name || + rows[0].table !== definition.table || + rows[0].event !== definition.event || + rows[0].timing !== 'AFTER' || + rows[0].body !== expectedBody || + Object.entries(context).some(([key, value]) => rows[0][key as keyof Context] !== value) + ) + return invalid() +} + +async function validateObjects(k: Knex, p: Plan, state: SnapshotJournalMysqlIntent): Promise { + if (state.nextObject > p.objects.length || (state.complete && state.nextObject !== p.objects.length)) return invalid() + const actual = await reserved(k) + const intent = actual.filter(object => object.name === SNAPSHOT_JOURNAL_MYSQL_INTENT && object.type === 'BASE TABLE') + if (intent.length !== 1) return invalid() + const expected = p.objects.slice(0, state.nextObject + 1) + if ( + actual.some( + object => + object.name !== SNAPSHOT_JOURNAL_MYSQL_INTENT && + !expected.some( + value => + value.definition.name === object.name && object.type === (value.type === 'table' ? 'BASE TABLE' : 'TRIGGER') + ) + ) + ) + return invalid() + let currentExists = false + await runInSeries(p.objects.entries(), async ([i, object]) => { + const found = actual.filter(row => row.name === object.definition.name) + if ((i < state.nextObject && found.length !== 1) || found.length > 1) return invalid() + if (found.length === 1) { + await validateObject(k, object, state, p.context) + if (i === state.nextObject) currentExists = true + } + }) + return currentExists +} + +async function validateState( + k: Knex, + p: Plan, + state: SnapshotJournalMysqlIntent +): Promise { + if (await k('snapshot_journal_events').first('revision')) return invalid() + const clocks = await k('snapshot_journal_clock').select('id', k.raw('CAST(ceiling AS CHAR) ceiling')).limit(2) + if (clocks.length !== 1 || clocks[0].id !== 1 || clocks[0].ceiling !== p.binding.ceiling) return invalid() + const progress = await k('snapshot_journal_bootstrap').select('*').limit(2) + if (progress.length !== 1) return invalid() + const row = progress[0] + if ( + row.id !== 1 || + !Number.isInteger(row.stream) || + row.stream < 0 || + row.stream > 17 || + !(row.cursor === null || (typeof row.cursor === 'string' && Buffer.byteLength(row.cursor, 'utf8') <= 2048)) || + (row.stream === 17 && row.cursor !== null) || + (state.complete && row.stream !== 17) + ) + return invalid() + const invalidations = await k('snapshot_journal_invalid').select('*').limit(2) + if ( + invalidations.length > 1 || + invalidations.some( + row => row.id !== 1 || !['capacity-exhausted', 'revision-exhausted', 'key-out-of-range'].includes(row.reason) + ) + ) + return invalid() + return { ...state, enabled: invalidations.length === 0 } +} + +/** Excludes concurrent migrators and schema changes. Every implicit-DDL step is + * preceded by durable intent; only that step may exist without its acknowledgement. + * Epoch markers and complete typed metadata are required on every resume. No SQL + * is loaded from persisted state, and no existing source/object is dropped. + */ +export async function installSnapshotJournalMysqlGeneration( + k: Knex, + ceiling: SnapshotJournalRevision, + config?: Knex.MigratorConfig +): Promise { + if (k.isTransaction) return invalid() + const p = await plan(k, ceiling, config) + const existing = await reserved(k) + let state = + existing.length === 0 + ? await createSnapshotJournalMysqlIntent(k, p.binding) + : await readSnapshotJournalMysqlIntent(k, p.binding) + const currentExists = await validateObjects(k, p, state) + const initial = state.nextObject + await runInSeries(p.objects.entries(), async ([i, object]) => { + if (i < initial) return + if (!(i === initial && currentExists)) { + if (object.type === 'trigger') + await k.raw(object.definition.sql.replace(object.definition.body, triggerBody(object.definition, state.epoch))) + else { + const table = object.definition + const sql = table.sql + " COMMENT='" + owner(state.epoch) + "'" + if (table.seed === 'clock') await k.raw(sql + ' SELECT 1 id,? ceiling', [ceiling]) + else if (table.seed === 'bootstrap') await k.raw(sql + ' SELECT 1 id,0 stream,NULL `cursor`') + else await k.raw(sql) + } + await validateObject(k, object, state, p.context) + } + const updated = await k(SNAPSHOT_JOURNAL_MYSQL_INTENT) + .where({ id: 1, epoch: state.epoch, nextObject: i, complete: 0 }) + .update({ nextObject: i + 1 }) + if (updated !== 1) return invalid() + state = { ...state, nextObject: i + 1 } + }) + if ((await readSnapshotJournalMysqlBinding(k, config)) !== p.binding.source) return invalid() + await validateObjects(k, p, state) + return await validateState(k, p, state) +} + +export async function readSnapshotJournalMysqlGeneration( + k: Knex, + ceiling: SnapshotJournalRevision, + config?: Knex.MigratorConfig +): Promise { + const p = await plan(k, ceiling, config), + state = await readSnapshotJournalMysqlIntent(k, p.binding) + if (state.nextObject !== p.objects.length) return invalid() + await validateObjects(k, p, state) + return await validateState(k, p, state) +} + +/** Publication still requires the registered migration journal in the reader view. */ +export async function completeSnapshotJournalMysqlGeneration( + k: Knex, + ceiling: SnapshotJournalRevision, + config?: Knex.MigratorConfig +): Promise { + const validated = await readSnapshotJournalMysqlGeneration(k, ceiling, config) + return await k.transaction(async t => { + if (!(await t('snapshot_journal_clock').where('id', 1).forUpdate().noWait().first('id'))) return invalid() + const state = await readSnapshotJournalMysqlIntent(t, validated) + if (state.epoch !== validated.epoch || state.nextObject !== 57) return invalid() + const progress = await t('snapshot_journal_bootstrap').where('id', 1).first('stream', 'cursor') + if ( + !progress || + progress.stream !== 17 || + progress.cursor !== null || + (await t('snapshot_journal_invalid').where('id', 1).first('id')) + ) + return invalid() + const updated = await t(SNAPSHOT_JOURNAL_MYSQL_INTENT) + .where({ id: 1, epoch: state.epoch, nextObject: 57 }) + .update({ complete: 1 }) + if (updated !== 1) return invalid() + return { ...state, complete: true, enabled: true } + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.test.ts new file mode 100644 index 000000000..b2b125c1f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.test.ts @@ -0,0 +1,239 @@ +import { knex, type Knex } from 'knex' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + createSnapshotJournalMysqlIntent, + readSnapshotJournalMysqlIntent, + type SnapshotJournalMysqlBinding +} from './SnapshotJournalMysqlIntent' +import { snapshotJournalRevision } from './SnapshotJournalRevision' +const binding: SnapshotJournalMysqlBinding = { + source: 'a'.repeat(64), + plan: 'b'.repeat(64), + ceiling: snapshotJournalRevision('9223372036854775807') +} +const epoch = '12345678-1234-4123-8123-123456789abc' +const captured: Record>> = JSON.parse( + readFileSync(join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-intent-metadata-fixture.json'), 'utf8') +) +async function fixture() { + const database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + await database.raw( + 'CREATE TABLE snapshot_journal_generation(id INTEGER,version INTEGER,epoch TEXT,source TEXT,plan TEXT,ceiling TEXT,nextObject INTEGER,complete INTEGER)' + ) + await database('snapshot_journal_generation').insert({ + id: 1, + version: 1, + epoch, + ...binding, + nextObject: 0, + complete: 0 + }) + const metadata = structuredClone(captured), + configuration = { client: { config: { client: 'mysql2' } }, isTransaction: false }, + version = { value: '8.4.0' } + const raw = jest.fn(async (sql: string, values?: unknown[]) => { + if (sql === 'SELECT VERSION() version') return [[{ version: version.value }]] + for (const [table, key] of [ + ['TABLES', 'tables'], + ['COLUMNS', 'columns'], + ['STATISTICS', 'indexes'], + ['TABLE_CONSTRAINTS', 'constraints'], + ['TRIGGERS', 'triggers'] + ]) + if (sql.includes('information_schema.' + table + ' ')) return [metadata[key]] + // Driver boundary only; native MySQL separately executes the atomic CTAS. + if (sql.startsWith('CREATE TABLE snapshot_journal_generation')) { + if (!values) throw new Error('Missing bound intent values') + await database('snapshot_journal_generation').update({ + epoch: values[0], + source: values[1], + plan: values[2], + ceiling: values[3] + }) + return {} + } + throw new Error('Unexpected SQL fixture request') + }) + const k = Object.assign((table: string) => database(table), configuration, { + raw + }) as unknown as Knex + return { k, database, metadata, raw, version } +} +test('native metadata and exact signed63 binding resume the persisted epoch', async () => { + const f = await fixture() + try { + expect(await readSnapshotJournalMysqlIntent(f.k, binding)).toEqual({ + ...binding, + epoch, + nextObject: 0, + complete: false + }) + await f.database('snapshot_journal_generation').update({ nextObject: 128, complete: 1 }) + expect(await readSnapshotJournalMysqlIntent(f.k, binding)).toEqual({ + ...binding, + epoch, + nextObject: 128, + complete: true + }) + } finally { + await f.database.destroy() + } +}) +test.each(['8.0.21', '8.0.40-commercial', '8.4.0'])( + 'supported %s binds intent values and reads back its newly generated epoch', + async version => { + const f = await fixture() + try { + f.version.value = version + const created = await createSnapshotJournalMysqlIntent(f.k, binding) + expect(created).toMatchObject({ ...binding, nextObject: 0, complete: false }) + expect(created.epoch).not.toBe(epoch) + expect(created.epoch).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/) + expect(f.raw.mock.calls.filter(([sql]) => sql.startsWith('CREATE TABLE'))).toHaveLength(1) + } finally { + await f.database.destroy() + } + } +) +test.each(['8.0.20', '5.7.44', '10.11.8-MariaDB', '9.0.0', 'unknown'])( + 'unsupported atomic-DDL baseline %s performs no DDL', + async version => { + const f = await fixture() + try { + f.version.value = version + await expect(createSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + expect(f.raw.mock.calls.map(([sql]) => sql)).toEqual(['SELECT VERSION() version']) + } finally { + await f.database.destroy() + } + } +) +test.each(['sqlite3', 'better-sqlite3', 'pg'])('unsupported driver %s refuses without I/O', async client => { + const f = await fixture() + try { + f.k.client.config.client = client + await expect(createSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + expect(f.raw).not.toHaveBeenCalled() + } finally { + await f.database.destroy() + } +}) +test('implicit DDL never commits a caller-owned transaction', async () => { + const f = await fixture() + try { + Object.defineProperty(f.k, 'isTransaction', { value: true }) + await expect(createSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + expect(f.raw).not.toHaveBeenCalled() + } finally { + await f.database.destroy() + } +}) +test.each([ + { source: 'A'.repeat(64) }, + { plan: 'bad' }, + { source: { toString: () => 'a'.repeat(64) } }, + { plan: { toString: () => 'b'.repeat(64) } }, + { ceiling: '0' }, + { ceiling: '01' }, + { ceiling: '9223372036854775808' } +])('invalid binding %j refuses before SQL', async patch => { + const f = await fixture() + try { + await expect( + createSnapshotJournalMysqlIntent(f.k, { ...binding, ...patch } as SnapshotJournalMysqlBinding) + ).rejects.toThrow() + expect(f.raw).not.toHaveBeenCalled() + } finally { + await f.database.destroy() + } +}) +test.each([ + ['tables', 'engine', 'MyISAM'], + ['tables', 'type', 'VIEW'], + ['tables', 'collation', 'ascii_general_ci'], + ['tables', 'rowFormat', 'Compact'], + ['tables', 'options', ''], + ['columns', 'name', 'foreign'], + ['columns', 'type', 'bigint'], + ['columns', 'nullable', 'YES'], + ['columns', 'defaultValue', 0], + ['columns', 'extra', 'auto_increment'], + ['columns', 'expression', '1'], + ['columns', 'charset', 'utf8mb4'], + ['columns', 'collation', 'utf8mb4_bin'], + ['indexes', 'name', 'foreign'], + ['indexes', 'columnName', 'source'], + ['indexes', 'nonUnique', 1], + ['indexes', 'direction', 'D'], + ['indexes', 'prefix', 1], + ['indexes', 'type', 'HASH'], + ['indexes', 'visible', 'NO'], + ['constraints', 'name', 'foreign'], + ['constraints', 'type', 'CHECK'], + ['constraints', 'enforced', 'NO'] +])('native %s %s drift refuses ownership', async (key, field, value) => { + const f = await fixture() + try { + f.metadata[key as string][0][field as string] = value + await expect(readSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) +test.each(['tables', 'columns', 'indexes', 'constraints'])('incomplete %s metadata refuses', async key => { + const f = await fixture() + try { + f.metadata[key].pop() + await expect(readSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) +test('an attached foreign observer refuses ownership', async () => { + const f = await fixture() + try { + f.metadata.triggers.push({ TRIGGER_NAME: 'foreign' }) + await expect(readSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) +test.each([ + { id: 2 }, + { version: 2 }, + { epoch: 'wrong' }, + { source: 'c'.repeat(64) }, + { plan: 'c'.repeat(64) }, + { ceiling: '1' }, + { nextObject: -1 }, + { nextObject: 129 }, + { nextObject: 0.5 }, + { complete: 2 } +])('persisted state %j cannot establish installation continuity', async patch => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update(patch) + await expect(readSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) +test.each(['missing', 'extra'])('%s intent row refuses', async kind => { + const f = await fixture() + try { + if (kind === 'missing') await f.database('snapshot_journal_generation').delete() + else + await f + .database('snapshot_journal_generation') + .insert({ ...(await f.database('snapshot_journal_generation').first()), id: 2 }) + await expect(readSnapshotJournalMysqlIntent(f.k, binding)).rejects.toThrow('Invalid or unowned') + } finally { + await f.database.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts new file mode 100644 index 000000000..c2dcd686a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts @@ -0,0 +1,185 @@ +import type { Knex } from 'knex' +import { randomUUID } from 'node:crypto' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' + +export const SNAPSHOT_JOURNAL_MYSQL_INTENT = 'snapshot_journal_generation' +export interface SnapshotJournalMysqlBinding { + source: string + plan: string + ceiling: SnapshotJournalRevision +} +export interface SnapshotJournalMysqlIntent extends SnapshotJournalMysqlBinding { + epoch: string + nextObject: number + complete: boolean +} +const columns = [ + ['id', 'int'], + ['version', 'int'], + ['epoch', 'varchar(36)'], + ['source', 'varchar(64)'], + ['plan', 'varchar(64)'], + ['ceiling', 'varchar(19)'], + ['nextObject', 'int'], + ['complete', 'tinyint'] +] as const +export const SNAPSHOT_JOURNAL_MYSQL_INTENT_DDL = + 'CREATE TABLE snapshot_journal_generation(id INTEGER NOT NULL PRIMARY KEY,version INTEGER NOT NULL,epoch VARCHAR(36) NOT NULL,source VARCHAR(64) NOT NULL,plan VARCHAR(64) NOT NULL,ceiling VARCHAR(19) NOT NULL,nextObject INTEGER NOT NULL,complete BOOLEAN NOT NULL) ENGINE=InnoDB DEFAULT CHARACTER SET ascii COLLATE ascii_bin ROW_FORMAT=DYNAMIC' +function invalid(): never { + throw new WERR_INVALID_OPERATION('Invalid or unowned MySQL snapshot journal installation intent') +} +function binding(value: SnapshotJournalMysqlBinding): void { + if ( + typeof value.source !== 'string' || + typeof value.plan !== 'string' || + !/^[0-9a-f]{64}$/.test(value.source) || + !/^[0-9a-f]{64}$/.test(value.plan) || + snapshotJournalRevision(value.ceiling) === '0' + ) + invalid() +} +function client(k: Knex): void { + if (k.client.config.client !== 'mysql' && k.client.config.client !== 'mysql2') invalid() +} +async function structure(k: Knex): Promise { + const [tables]: Array< + Array<{ engine: string; type: string; collation: string; rowFormat: string; options: string }> + > = await k.raw( + 'SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [SNAPSHOT_JOURNAL_MYSQL_INTENT] + ) + if ( + tables.length !== 1 || + tables[0].engine !== 'InnoDB' || + tables[0].type !== 'BASE TABLE' || + tables[0].collation !== 'ascii_bin' || + tables[0].rowFormat !== 'Dynamic' || + tables[0].options !== 'row_format=DYNAMIC' + ) + return invalid() + const [actual]: Array< + Array<{ + name: string + type: string + nullable: string + defaultValue: unknown + extra: string + charset: string | null + collation: string | null + expression: string + }> + > = await k.raw( + 'SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9', + [SNAPSHOT_JOURNAL_MYSQL_INTENT] + ) + if ( + actual.length !== columns.length || + actual.some((column, index) => { + const [name, type] = columns[index], + text = type.startsWith('varchar') + return ( + column.name !== name || + column.type.replace(/^(int|tinyint)\(\d+\)$/, '$1') !== type || + column.nullable !== 'NO' || + column.defaultValue !== null || + column.extra !== '' || + column.expression !== '' || + column.charset !== (text ? 'ascii' : null) || + column.collation !== (text ? 'ascii_bin' : null) + ) + }) + ) + return invalid() + const [indexes]: Array< + Array<{ + name: string + columnName: string + nonUnique: number + direction: string + prefix: unknown + type: string + visible: string + }> + > = await k.raw( + 'SELECT INDEX_NAME name,COLUMN_NAME columnName,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 2', + [SNAPSHOT_JOURNAL_MYSQL_INTENT] + ) + if ( + indexes.length !== 1 || + indexes[0].name !== 'PRIMARY' || + indexes[0].columnName !== 'id' || + indexes[0].nonUnique !== 0 || + indexes[0].direction !== 'A' || + indexes[0].prefix !== null || + indexes[0].type !== 'BTREE' || + indexes[0].visible !== 'YES' + ) + return invalid() + const [constraints]: Array> = await k.raw( + 'SELECT CONSTRAINT_NAME name,CONSTRAINT_TYPE type,ENFORCED enforced FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? LIMIT 2', + [SNAPSHOT_JOURNAL_MYSQL_INTENT] + ) + if ( + constraints.length !== 1 || + constraints[0].name !== 'PRIMARY' || + constraints[0].type !== 'PRIMARY KEY' || + constraints[0].enforced !== 'YES' + ) + return invalid() + const [triggers]: Array = await k.raw( + 'SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1', + [SNAPSHOT_JOURNAL_MYSQL_INTENT] + ) + if (triggers.length !== 0) return invalid() +} +export async function readSnapshotJournalMysqlIntent( + k: Knex, + expected: SnapshotJournalMysqlBinding +): Promise { + client(k) + binding(expected) + await structure(k) + const rows = await k(SNAPSHOT_JOURNAL_MYSQL_INTENT).select('*').limit(2) + if (rows.length !== 1) return invalid() + const row = rows[0] + if ( + row.id !== 1 || + row.version !== 1 || + typeof row.epoch !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(row.epoch) || + row.source !== expected.source || + row.plan !== expected.plan || + row.ceiling !== expected.ceiling || + !Number.isInteger(row.nextObject) || + row.nextObject < 0 || + row.nextObject > 128 || + ![0, 1].includes(row.complete) + ) + return invalid() + return { ...expected, epoch: row.epoch, nextObject: row.nextObject, complete: row.complete === 1 } +} + +/** MySQL 8.0.21+ InnoDB atomic CREATE TABLE SELECT persists the first intent row + * with its table. Caller excludes other migrators and validates source/schema + * before calling; no existing object is adopted, replaced or dropped here. + * This is not a transaction wrapper: ordinary MySQL DDL commits implicitly. + */ +export async function createSnapshotJournalMysqlIntent( + k: Knex, + expected: SnapshotJournalMysqlBinding +): Promise { + client(k) + binding(expected) + if (k.isTransaction) return invalid() + const [[server]]: Array> = await k.raw('SELECT VERSION() version') + const version = /^8\.(\d+)\.(\d+)(?:[-.]|$)/.exec(server.version) + if (!version || (Number(version[1]) === 0 && Number(version[2]) < 21)) return invalid() + const epoch = randomUUID() + await k.raw( + SNAPSHOT_JOURNAL_MYSQL_INTENT_DDL + + ' SELECT 1 id,1 version,? epoch,? source,? plan,? ceiling,0 nextObject,0 complete', + [epoch, expected.source, expected.plan, expected.ceiling] + ) + return await readSnapshotJournalMysqlIntent(k, expected) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts new file mode 100644 index 000000000..15ca35df5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts @@ -0,0 +1,158 @@ +import type { Knex } from 'knex' +import { runInSeries } from '../../../utility/runInSeries' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { numeric } from '../../schema/snapshotSqliteMembership' +import { + SNAPSHOT_JOURNAL_MYSQL_CLOCK_VARIABLES as variables, + SNAPSHOT_JOURNAL_MYSQL_ADVANCE as advance +} from './SnapshotJournalMysqlClock' + +const tables = [...numeric.map(source => source.table), 'tx_labels_map', 'output_tags_map', 'certificate_fields'] +const q = (name: string) => '`' + name.replaceAll('`', '``') + '`' +const physicalKey = 'tableId,id1,id2,exactText', + scopeKey = 'tableId,userId,id1,id2,exactText' +export const SNAPSHOT_JOURNAL_MYSQL_METADATA_DDL = [ + `CREATE TABLE snapshot_journal_physical(tableId INT NOT NULL,id1 BIGINT UNSIGNED NOT NULL,id2 BIGINT UNSIGNED NOT NULL,exactText VARBINARY(400) NOT NULL,revision BIGINT UNSIGNED NOT NULL,generation BIGINT UNSIGNED NOT NULL,present BOOLEAN NOT NULL,PRIMARY KEY(${physicalKey}),KEY snapshot_journal_physical_page(tableId,revision,id1,id2,exactText)) ENGINE=InnoDB`, + `CREATE TABLE snapshot_journal_scope(tableId INT NOT NULL,userId BIGINT UNSIGNED NOT NULL,id1 BIGINT UNSIGNED NOT NULL,id2 BIGINT UNSIGNED NOT NULL,exactText VARBINARY(400) NOT NULL,revision BIGINT UNSIGNED NOT NULL,present BOOLEAN NOT NULL,PRIMARY KEY(${scopeKey}),KEY snapshot_journal_scope_page(userId,tableId,revision,id1,id2,exactText)) ENGINE=InnoDB` +] +const tuple = (table: string, p: string): string[] => { + const key = numeric.find(source => source.table === table)?.key + return key + ? [p + '.' + q(key), '0', "CAST('' AS BINARY)"] + : table === 'tx_labels_map' + ? [p + '.txLabelId', p + '.transactionId', "CAST('' AS BINARY)"] + : table === 'output_tags_map' + ? [p + '.outputTagId', p + '.outputId', "CAST('' AS BINARY)"] + : [p + '.certificateId', '0', 'CAST(' + p + '.fieldName AS BINARY)'] +} +const scopeUpsert = (selection: string) => + `INSERT INTO snapshot_journal_scope(${scopeKey},revision,present) ${selection} ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); ` +function keyGuard(key: string[], owner?: string): string { + const [id1, id2, text] = key + const valid = [ + `(${id1} BETWEEN 1 AND 9007199254740991)`, + `(${id2} BETWEEN 0 AND 9007199254740991)`, + `(OCTET_LENGTH(${text})<=400)`, + ...(owner ? [`(${owner} BETWEEN 1 AND 9007199254740991)`] : []) + ].join(' AND ') + return `IF NOT COALESCE((${valid}),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; ` +} + +/** Caller validates the completed MySQL membership/source schema before installing these observers. */ +export async function snapshotJournalMysqlObserverSql(k: Knex): Promise { + if (!['mysql', 'mysql2'].includes(k.client.config.client)) + throw new WERR_INVALID_OPERATION('Snapshot journal observers require MySQL') + const definitions: string[] = [] + const membership: Array<{ + table: string + expressions: (p: string) => string[] + present: (p: string) => string + }> = [ + { + table: 'snapshot_profile_keys', + expressions: p => [ + p + '.snapshotTableId', + p + '.snapshotUserId', + p + '.snapshotRowId', + '0', + "CAST('' AS BINARY)" + ], + present: () => '1' + }, + { + table: 'snapshot_relation_keys', + expressions: p => [ + p + '.snapshotTableId+10', + p + '.snapshotUserId', + p + '.snapshotLeftId', + p + '.snapshotRightId', + "CAST('' AS BINARY)" + ], + present: p => '(' + p + '.snapshotMembership<>0)' + }, + { + table: 'snapshot_certificate_field_keys', + expressions: p => [ + '12', + p + '.snapshotUserId', + p + '.snapshotCertificateId', + '0', + 'CAST(' + p + '.snapshotFieldName AS BINARY)' + ], + present: p => '(' + p + '.snapshotMembership<>0)' + }, + { + table: 'snapshot_global_keys', + expressions: p => [ + 'CASE ' + p + '.tableId WHEN 0 THEN 9 ELSE 8 END', + p + '.userId', + p + '.rowId', + '0', + "CAST('' AS BINARY)" + ], + present: p => p + '.present' + } + ] + for (const [i, source] of membership.entries()) + for (const event of ['INSERT', 'DELETE', 'UPDATE']) { + const p = event === 'DELETE' ? 'OLD' : 'NEW', + fields = source.expressions(p) + const condition = + event === 'UPDATE' + ? [ + ...source.expressions('OLD').map((x, j) => `NOT (${x} <=> ${source.expressions('NEW')[j]})`), + `NOT (${source.present('OLD')} <=> ${source.present('NEW')})` + ].join(' OR ') + : 'TRUE' + definitions.push( + `CREATE TRIGGER snapshot_journal_scope_${i}_${event} AFTER ${event} ON ${q(source.table)} FOR EACH ROW BEGIN ${variables}IF ${condition} THEN ${advance}${keyGuard(fields.slice(2), fields[1])}IF journalEnabled THEN ${scopeUpsert('SELECT ' + [...fields, 'journalRevision', event === 'DELETE' ? '0' : source.present(p)].join(','))} END IF; END IF; END` + ) + } + await runInSeries(tables.entries(), async ([tableId, table]) => { + const [columns]: Array> = await k.raw( + 'SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION', + [table] + ) + if (columns.length === 0) throw new WERR_INVALID_OPERATION('Missing snapshot journal source') + const changed = columns + .map(({ name }) => `NOT (CAST(OLD.${q(name)} AS BINARY) <=> CAST(NEW.${q(name)} AS BINARY))`) + .join(' OR ') + const same = tuple(table, 'OLD') + .map((x, i) => `(${x} <=> ${tuple(table, 'NEW')[i]})`) + .join(' AND ') + const write = (p: string, present: number, fresh: string) => + `INSERT INTO snapshot_journal_physical(${physicalKey},revision,generation,present) VALUES(${tableId},${tuple(table, p).join(',')},journalRevision,journalRevision,${present}) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN ${fresh} THEN VALUES(generation) ELSE generation END,present=VALUES(present); ` + for (const event of ['INSERT', 'UPDATE', 'DELETE']) { + const p = event === 'DELETE' ? 'OLD' : 'NEW', + keys = tuple(table, p) + let body = advance + keyGuard(keys, tableId < 8 ? p + '.userId' : undefined) + if (event === 'UPDATE') body += keyGuard(tuple(table, 'OLD'), tableId < 8 ? 'OLD.userId' : undefined) + body += 'IF journalEnabled THEN ' + if (event === 'UPDATE') body += `IF NOT (${same}) THEN ${write('OLD', 0, 'FALSE')} END IF; ` + body += write( + p, + event === 'DELETE' ? 0 : 1, + event === 'INSERT' ? 'TRUE' : event === 'UPDATE' ? `NOT (${same})` : 'FALSE' + ) + if (event !== 'DELETE') { + if (tableId < 8) + body += scopeUpsert( + `SELECT ${tableId},NEW.userId,NEW.${q(numeric[tableId].key)},0,CAST('' AS BINARY),journalRevision,1` + ) + else if (tableId === 10 || tableId === 11) + body += scopeUpsert( + `SELECT ${tableId},snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=${tableId - 10} AND snapshotLeftId=${keys[0]} AND snapshotRightId=${keys[1]} AND snapshotMembership<>0 FOR SHARE` + ) + else if (tableId === 12) + body += scopeUpsert( + `SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE` + ) + } + body += 'END IF; ' + definitions.push( + `CREATE TRIGGER snapshot_journal_physical_${tableId}_${event} AFTER ${event} ON ${q(table)} FOR EACH ROW BEGIN ${variables}IF ${event === 'UPDATE' ? changed : 'TRUE'} THEN ${body} END IF; END` + ) + } + }) + return definitions +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.test.ts new file mode 100644 index 000000000..be3330336 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.test.ts @@ -0,0 +1,383 @@ +import { knex, type Knex } from 'knex' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import * as profile from '../../schema/snapshotProfileIndexMigration' +import * as relation from '../../schema/snapshotRelationIndexMigration' +import * as certificate from '../../schema/snapshotCertificateIndexMigration' +import * as global from '../../schema/snapshotGlobalIndexMigration' +import { validateSnapshotJournalMysqlSource, readSnapshotJournalMysqlBinding } from './SnapshotJournalMysqlSource' + +// Native MySQL 8.4 metadata from a real registered migration, queried through a +// SQLite information_schema fixture so predicates/order/limits execute in tests. +// Native qualification separately verifies MySQL's actual metadata semantics. +const fixture: Record>> = JSON.parse( + readFileSync(join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-source-metadata-fixture.json'), 'utf8') +) +const families = [ + [ + 'TABLES', + 'tables', + { + name: 'TABLE_NAME', + engine: 'ENGINE', + type: 'TABLE_TYPE', + collation: 'TABLE_COLLATION', + rowFormat: 'ROW_FORMAT', + options: 'CREATE_OPTIONS' + } + ], + [ + 'COLUMNS', + 'columns', + { + tableName: 'TABLE_NAME', + name: 'COLUMN_NAME', + position: 'ORDINAL_POSITION', + type: 'COLUMN_TYPE', + nullable: 'IS_NULLABLE', + defaultValue: 'COLUMN_DEFAULT', + extra: 'EXTRA', + charset: 'CHARACTER_SET_NAME', + collation: 'COLLATION_NAME', + expression: 'GENERATION_EXPRESSION' + } + ], + [ + 'STATISTICS', + 'indexes', + { + tableName: 'TABLE_NAME', + name: 'INDEX_NAME', + position: 'SEQ_IN_INDEX', + columnName: 'COLUMN_NAME', + nonUnique: 'NON_UNIQUE', + direction: 'COLLATION', + prefix: 'SUB_PART', + nullable: 'NULLABLE', + type: 'INDEX_TYPE', + visible: 'IS_VISIBLE', + expression: 'EXPRESSION' + } + ], + [ + 'KEY_COLUMN_USAGE', + 'foreignKeys', + { + tableName: 'TABLE_NAME', + name: 'CONSTRAINT_NAME', + position: 'ORDINAL_POSITION', + columnName: 'COLUMN_NAME', + foreignSchema: 'REFERENCED_TABLE_SCHEMA', + foreignTable: 'REFERENCED_TABLE_NAME', + foreignColumn: 'REFERENCED_COLUMN_NAME' + } + ], + [ + 'REFERENTIAL_CONSTRAINTS', + 'foreignRules', + { + tableName: 'TABLE_NAME', + name: 'CONSTRAINT_NAME', + matchOption: 'MATCH_OPTION', + updateRule: 'UPDATE_RULE', + deleteRule: 'DELETE_RULE' + } + ], + [ + 'TRIGGERS', + 'triggers', + { + tableName: 'EVENT_OBJECT_TABLE', + name: 'TRIGGER_NAME', + event: 'EVENT_MANIPULATION', + timing: 'ACTION_TIMING', + actionOrder: 'ACTION_ORDER', + definer: 'DEFINER', + body: 'ACTION_STATEMENT', + sqlMode: 'SQL_MODE', + charset: 'CHARACTER_SET_CLIENT', + connectionCollation: 'COLLATION_CONNECTION', + databaseCollation: 'DATABASE_COLLATION' + } + ] +] as const +let reads: Array +beforeEach(() => { + reads = [ + jest.spyOn(profile, 'readSnapshotProfileIndexState'), + jest.spyOn(relation, 'readSnapshotRelationIndexState'), + jest.spyOn(certificate, 'readSnapshotCertificateIndexState'), + jest.spyOn(global, 'readSnapshotGlobalIndexState') + ] + for (const read of reads) read.mockResolvedValue(true) +}) +afterEach(() => jest.restoreAllMocks()) +async function metadata(): Promise { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const connection = await k.client.acquireConnection() + try { + connection.function('DATABASE', () => 'ts569_snapshot') + } finally { + await k.client.releaseConnection(connection) + } + await k.raw("ATTACH DATABASE ':memory:' AS information_schema") + for (const [table, key, columns] of families) { + const schemaColumns = ['TABLE_SCHEMA', 'CONSTRAINT_SCHEMA', 'TRIGGER_SCHEMA', ...Object.values(columns)] + await k.raw('CREATE TABLE information_schema.?? (' + schemaColumns.map(() => '??').join(',') + ')', [ + table, + ...schemaColumns + ]) + for (const row of fixture[key]) { + const record: Record = { + TABLE_SCHEMA: 'ts569_snapshot', + CONSTRAINT_SCHEMA: 'ts569_snapshot', + TRIGGER_SCHEMA: 'ts569_snapshot' + } + for (const [field, column] of Object.entries(columns)) record[column] = row[field] + await k(table).withSchema('information_schema').insert(record) + } + } + await k.raw( + 'CREATE TABLE information_schema.TABLE_CONSTRAINTS(TABLE_SCHEMA,CONSTRAINT_SCHEMA,TABLE_NAME,CONSTRAINT_NAME,CONSTRAINT_TYPE,ENFORCED)' + ) + await k.raw('CREATE TABLE information_schema.CHECK_CONSTRAINTS(CONSTRAINT_SCHEMA,CONSTRAINT_NAME,CHECK_CLAUSE)') + const original = k.client.processResponse.bind(k.client) + // A MySQL driver's raw result wraps rows; execute identical SELECT predicates. + jest.spyOn(k.client, 'processResponse').mockImplementation((...args: unknown[]) => { + const response = args[0] as { method: string; sql: string } + const result = original(...args) + return response.method === 'raw' && response.sql.startsWith('SELECT') ? [result] : result + }) + k.client.config.client = 'mysql2' + return k +} + +test('published native metadata produces a stable source binding through real predicates', async () => { + const k = await metadata() + try { + await validateSnapshotJournalMysqlSource(k) + const binding = await readSnapshotJournalMysqlBinding(k) + expect(binding).toMatch(/^[0-9a-f]{64}$/) + expect(await readSnapshotJournalMysqlBinding(k)).toBe(binding) + } finally { + await k.destroy() + } +}) +test.each(['sqlite3', 'better-sqlite3', 'pg', 'missing'])( + 'unsupported source %s refuses before database access', + async client => { + const k = await metadata() + try { + k.client.config.client = client + const queries = jest.fn() + k.on('query', queries) + await expect(validateSnapshotJournalMysqlSource(k)).rejects.toThrow('Unsupported or incomplete') + expect(queries).not.toHaveBeenCalled() + for (const read of reads) expect(read).not.toHaveBeenCalled() + } finally { + await k.destroy() + } + } +) +test.each([0, 1, 2, 3])('unpublished prerequisite %s prevents journal admission', async index => { + const k = await metadata() + try { + reads[index].mockResolvedValue(false) + await expect(validateSnapshotJournalMysqlSource(k)).rejects.toThrow('Unsupported or incomplete') + } finally { + await k.destroy() + } +}) +test.each(['missing', 'engine', 'view'])('source %s is refused', async kind => { + const k = await metadata() + try { + const table = k('TABLES').withSchema('information_schema').where('TABLE_NAME', 'outputs') + if (kind === 'missing') await table.delete() + else await table.update(kind === 'engine' ? { ENGINE: 'MyISAM' } : { TABLE_TYPE: 'VIEW' }) + await expect(validateSnapshotJournalMysqlSource(k)).rejects.toThrow('Unsupported or incomplete') + } finally { + await k.destroy() + } +}) +test.each([ + ['UPDATE_RULE', 'CASCADE'], + ['DELETE_RULE', 'CASCADE'], + ['UPDATE_RULE', 'SET NULL'], + ['DELETE_RULE', 'SET NULL'] +])('%s %s cannot bypass source observers', async (column, value) => { + const k = await metadata() + try { + await k('REFERENTIAL_CONSTRAINTS') + .withSchema('information_schema') + .where('TABLE_NAME', 'outputs') + .update({ [column]: value }) + await expect(validateSnapshotJournalMysqlSource(k)).rejects.toThrow('Unsupported or incomplete') + } finally { + await k.destroy() + } +}) +test.each(['missing', 'table', 'event', 'timing', 'body'])('changed prerequisite trigger %s is refused', async kind => { + const k = await metadata() + try { + const query = k('TRIGGERS').withSchema('information_schema').where('TRIGGER_NAME', 'snapshot_profile_0_insert') + if (kind === 'missing') await query.delete() + else + await query.update({ + [{ + table: 'EVENT_OBJECT_TABLE', + event: 'EVENT_MANIPULATION', + timing: 'ACTION_TIMING', + body: 'ACTION_STATEMENT' + }[kind]!]: 'changed' + }) + await expect(validateSnapshotJournalMysqlSource(k)).rejects.toThrow('Unsupported or incomplete') + } finally { + await k.destroy() + } +}) +test.each([ + ['COLUMNS', 'COLUMN_DEFAULT', 'literal with spaces'], + ['STATISTICS', 'IS_VISIBLE', 'NO'], + ['REFERENTIAL_CONSTRAINTS', 'MATCH_OPTION', 'changed'], + ['KEY_COLUMN_USAGE', 'REFERENCED_COLUMN_NAME', 'changed'], + ['TABLES', 'ROW_FORMAT', 'COMPACT'] +])('binding retains %s %s semantics', async (table, column, value) => { + const k = await metadata() + try { + const before = await readSnapshotJournalMysqlBinding(k) + const update = k(table).withSchema('information_schema') + if (table === 'STATISTICS') update.where('NON_UNIQUE', 1) + await update.update({ [column]: value }) + expect(await readSnapshotJournalMysqlBinding(k)).not.toBe(before) + } finally { + await k.destroy() + } +}) +test('literal bytes and foreign objects with a journal prefix remain in the binding', async () => { + const k = await metadata() + try { + const before = await readSnapshotJournalMysqlBinding(k) + const original = fixture.triggers[0] + const add: Record = { TRIGGER_SCHEMA: 'ts569_snapshot' } + for (const [field, column] of Object.entries(families[5][2])) add[column] = original[field] + Object.assign(add, { + TRIGGER_NAME: 'snapshot_journal_foreign', + ACTION_STATEMENT: "BEGIN DO 'a b'; END" + }) + await k('TRIGGERS').withSchema('information_schema').insert(add) + const first = await readSnapshotJournalMysqlBinding(k) + expect(first).not.toBe(before) + await k('TRIGGERS') + .withSchema('information_schema') + .where('TRIGGER_NAME', 'snapshot_journal_foreign') + .update({ ACTION_STATEMENT: "BEGIN DO 'a b'; END" }) + expect(await readSnapshotJournalMysqlBinding(k)).not.toBe(first) + } finally { + await k.destroy() + } +}) +test.each(['rows', 'value', 'aggregate'])( + 'oversized %s metadata refuses instead of binding a truncated definition', + async kind => { + const k = await metadata() + try { + if (kind === 'value') + await k('COLUMNS') + .withSchema('information_schema') + .update({ COLUMN_DEFAULT: 'x'.repeat(16385) }) + else if (kind === 'aggregate') + await k('COLUMNS') + .withSchema('information_schema') + .update({ COLUMN_DEFAULT: 'x'.repeat(16384) }) + else { + const original = fixture.indexes[0] + for (let i = 0; i < 513; i++) { + const row: Record = { TABLE_SCHEMA: 'ts569_snapshot' } + for (const [field, column] of Object.entries(families[2][2])) row[column] = original[field] + row.INDEX_NAME = 'bounded' + i + await k('STATISTICS').withSchema('information_schema').insert(row) + } + } + await expect(readSnapshotJournalMysqlBinding(k)).rejects.toThrow('Unsupported or incomplete') + } finally { + await k.destroy() + } + } +) + +test.each([ + 'missing primary', + 'composite primary', + 'prefix identity', + 'descending identity', + 'invisible identity', + 'nullable owner', + 'signed identity', + 'generated owner' +])('unsupported source identity %s refuses before journal installation', async kind => { + const k = await metadata() + try { + const index = k('STATISTICS') + .withSchema('information_schema') + .where({ TABLE_NAME: 'tx_labels', INDEX_NAME: 'PRIMARY' }) + if (kind === 'missing primary') await index.delete() + if (kind === 'composite primary') { + const row = await index.first() + await k('STATISTICS') + .withSchema('information_schema') + .insert({ ...row, SEQ_IN_INDEX: 2, COLUMN_NAME: 'userId' }) + } + if (kind === 'prefix identity') await index.update({ SUB_PART: 1 }) + if (kind === 'descending identity') await index.update({ COLLATION: 'D' }) + if (kind === 'invisible identity') await index.update({ IS_VISIBLE: 'NO' }) + if (kind === 'nullable owner') + await k('COLUMNS') + .withSchema('information_schema') + .where({ TABLE_NAME: 'tx_labels', COLUMN_NAME: 'userId' }) + .update({ IS_NULLABLE: 'YES' }) + if (kind === 'signed identity') + await k('COLUMNS') + .withSchema('information_schema') + .where({ TABLE_NAME: 'tx_labels', COLUMN_NAME: 'txLabelId' }) + .update({ COLUMN_TYPE: 'int' }) + if (kind === 'generated owner') + await k('COLUMNS') + .withSchema('information_schema') + .where({ TABLE_NAME: 'tx_labels', COLUMN_NAME: 'userId' }) + .update({ EXTRA: 'VIRTUAL GENERATED' }) + await expect(validateSnapshotJournalMysqlSource(k)).rejects.toThrow('Unsupported or incomplete') + } finally { + await k.destroy() + } +}) + +test.each(['missing', 'non-InnoDB', 'view'])( + 'binding detects source table %s after prerequisite validation', + async kind => { + const k = await metadata(), + process = jest.mocked(k.client.processResponse).getMockImplementation()! + let altered = false + jest.mocked(k.client.processResponse).mockImplementation((...args: unknown[]) => { + const query = args[0] as { sql: string }, + result = process(...args) + if (query.sql.includes('ROW_FORMAT rowFormat')) { + altered = true + if (kind === 'missing') result[0].pop() + if (kind === 'non-InnoDB') result[0][0].engine = 'MyISAM' + if (kind === 'view') result[0][0].type = 'VIEW' + } + return result + }) + try { + await expect(readSnapshotJournalMysqlBinding(k)).rejects.toThrow('Unsupported or incomplete') + expect(altered).toBe(true) + } finally { + await k.destroy() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts new file mode 100644 index 000000000..e29eae4eb --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts @@ -0,0 +1,253 @@ +import type { Knex } from 'knex' +import { createHash } from 'node:crypto' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { numeric } from '../../schema/snapshotSqliteMembership' +import { snapshotGlobalIndexTriggers } from '../../schema/snapshotGlobalIndexTriggers' +import { readSnapshotGlobalIndexState } from '../../schema/snapshotGlobalIndexMigration' +import { + snapshotProfileTriggerDefinition, + readSnapshotProfileIndexState +} from '../../schema/snapshotProfileIndexMigration' +import { + snapshotRelationIndexTriggers, + readSnapshotRelationIndexState +} from '../../schema/snapshotRelationIndexMigration' +import { + snapshotCertificateIndexTriggers, + readSnapshotCertificateIndexState +} from '../../schema/snapshotCertificateIndexMigration' + +function invalid(): never { + throw new WERR_INVALID_OPERATION('Unsupported or incomplete MySQL snapshot journal source') +} +/** Read-only prerequisite guard; ownership/install intents and epoch/receipt provenance remain the migrator's responsibility. */ +export async function validateSnapshotJournalMysqlSource(k: Knex, config?: Knex.MigratorConfig): Promise { + if (!['mysql', 'mysql2'].includes(k.client.config.client)) return invalid() + await runInSeries( + [ + readSnapshotProfileIndexState, + readSnapshotRelationIndexState, + readSnapshotCertificateIndexState, + readSnapshotGlobalIndexState + ], + async read => { + if (!(await read(k, config))) invalid() + } + ) + const sourceTables = [ + ...numeric.map(source => source.table), + 'tx_labels_map', + 'output_tags_map', + 'certificate_fields' + ] + const [tables]: Array> = await k.raw( + 'SELECT TABLE_NAME name,ENGINE engine,TABLE_TYPE type FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (' + + sourceTables.map(() => '?').join(',') + + ')', + sourceTables + ) + if ( + tables.length !== sourceTables.length || + tables.some(table => table.engine !== 'InnoDB' || table.type !== 'BASE TABLE') + ) + return invalid() + const [columns]: Array> = + await k.raw( + 'SELECT TABLE_NAME tableName,COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,EXTRA extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (' + + sourceTables.map(() => '?').join(',') + + ') ORDER BY TABLE_NAME,ORDINAL_POSITION LIMIT 513', + sourceTables + ) + const [parts]: Array< + Array<{ + tableName: string + name: string + columnName: string + nonUnique: number + direction: string + prefix: unknown + visible: string + }> + > = await k.raw( + 'SELECT TABLE_NAME tableName,INDEX_NAME name,COLUMN_NAME columnName,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,IS_VISIBLE visible FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (' + + sourceTables.map(() => '?').join(',') + + ') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX LIMIT 513', + sourceTables + ) + if (columns.length > 512 || parts.length > 512) return invalid() + for (const source of numeric) { + for (const name of [source.key, ...(source.owner ? [source.owner] : [])]) { + const found = columns.filter(column => column.tableName === source.table && column.name === name) + if ( + found.length !== 1 || + found[0].type.replaceAll(/\(\d+\)/g, '') !== 'int unsigned' || + found[0].nullable !== 'NO' || + !(found[0].extra === '' || (name === source.key && found[0].extra === 'auto_increment')) + ) + return invalid() + } + } + const identities = [ + ...numeric.map(source => ({ table: source.table, keys: [source.key], primary: true })), + { table: 'tx_labels_map', keys: ['txLabelId', 'transactionId'], primary: false }, + { table: 'output_tags_map', keys: ['outputTagId', 'outputId'], primary: false }, + { table: 'certificate_fields', keys: ['fieldName', 'certificateId'], primary: false } + ] + for (const source of identities) { + const candidates = parts.filter(part => part.tableName === source.table && part.nonUnique === 0) + const matches = [...new Set(candidates.map(part => part.name))].some(name => { + const index = candidates.filter(part => part.name === name) + return ( + (!source.primary || name === 'PRIMARY') && + index.length === source.keys.length && + index.every( + (part, i) => + part.columnName === source.keys[i] && + part.direction === 'A' && + part.prefix === null && + part.visible === 'YES' + ) + ) + }) + if (!matches) return invalid() + } + const [rules]: Array> = await k.raw( + 'SELECT UPDATE_RULE updateRule,DELETE_RULE deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME IN (' + + sourceTables.map(() => '?').join(',') + + ')', + sourceTables + ) + if ( + rules.some( + rule => ![rule.updateRule, rule.deleteRule].every(value => value === 'RESTRICT' || value === 'NO ACTION') + ) + ) + return invalid() + const profile = numeric + .filter(source => source.owner !== undefined) + .flatMap((source, tableId) => + (['INSERT', 'UPDATE', 'DELETE'] as const).map(event => ({ + ...snapshotProfileTriggerDefinition(true, source.table, source.key, tableId, event), + table: source.table, + event, + timing: 'AFTER' + })) + ) + const relation = snapshotRelationIndexTriggers(true), + certificate = snapshotCertificateIndexTriggers(true) + const expected = [ + ...profile, + ...relation.observers, + ...relation.producers, + ...certificate.observers, + ...certificate.producers, + ...snapshotGlobalIndexTriggers(true) + ] + const maximum = Math.max(...expected.map(trigger => Buffer.byteLength(trigger.body, 'utf8'))) + 1 + const [actual]: Array> = + await k.raw( + 'SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME IN (' + + expected.map(() => '?').join(',') + + ')', + [maximum, ...expected.map(trigger => trigger.name)] + ) + if (actual.length !== expected.length) return invalid() + for (const trigger of expected) { + const found = actual.filter(row => row.name === trigger.name) + if ( + found.length !== 1 || + found[0].table !== trigger.table || + found[0].event !== trigger.event || + found[0].timing !== trigger.timing || + found[0].body !== trigger.body + ) + return invalid() + } +} + +/** Bind static source semantics, never row counts, statistics or AUTO_INCREMENT positions. + * Call only with operator-owned schema changes excluded; this read is not a DDL lock. + * The epoch owner persists the returned digest and refuses a different binding. + */ +export async function readSnapshotJournalMysqlBinding(k: Knex, config?: Knex.MigratorConfig): Promise { + await validateSnapshotJournalMysqlSource(k, config) + const tables = [ + ...numeric.map(source => source.table), + 'tx_labels_map', + 'output_tags_map', + 'certificate_fields', + 'users', + 'settings', + 'snapshot_profile_keys', + 'snapshot_relation_keys', + 'snapshot_certificate_field_keys', + 'snapshot_global_edges', + 'snapshot_global_keys', + 'snapshot_global_guards' + ] + const marks = tables.map(() => '?').join(',') + const journalNames = [ + ...Array.from({ length: 4 }, (_, i) => 'snapshot_journal_scope_' + i), + ...Array.from({ length: 13 }, (_, i) => 'snapshot_journal_physical_' + i) + ].flatMap(prefix => ['INSERT', 'UPDATE', 'DELETE'].map(event => prefix + '_' + event)) + // Hard query cardinality and value limits keep metadata capture independent of wallet size. + // Reject truncation rather than hashing a prefix as if it described the whole schema. + const bound = (column: string) => `SUBSTRING(${column},1,16385)` + const definitions = [ + [ + 'tables', + `SELECT TABLE_NAME name,ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,${bound('CREATE_OPTIONS')} options FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (${marks}) ORDER BY TABLE_NAME` + ], + [ + 'columns', + `SELECT TABLE_NAME tableName,COLUMN_NAME name,ORDINAL_POSITION position,COLUMN_TYPE type,IS_NULLABLE nullable,${bound('COLUMN_DEFAULT')} defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,${bound('GENERATION_EXPRESSION')} expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (${marks}) ORDER BY TABLE_NAME,ORDINAL_POSITION` + ], + [ + 'indexes', + `SELECT TABLE_NAME tableName,INDEX_NAME name,SEQ_IN_INDEX position,COLUMN_NAME columnName,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,NULLABLE nullable,INDEX_TYPE type,IS_VISIBLE visible,${bound('EXPRESSION')} expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (${marks}) ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX` + ], + [ + 'foreignKeys', + `SELECT TABLE_NAME tableName,CONSTRAINT_NAME name,ORDINAL_POSITION position,COLUMN_NAME columnName,REFERENCED_TABLE_SCHEMA foreignSchema,REFERENCED_TABLE_NAME foreignTable,REFERENCED_COLUMN_NAME foreignColumn FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (${marks}) AND REFERENCED_TABLE_NAME IS NOT NULL ORDER BY TABLE_NAME,CONSTRAINT_NAME,ORDINAL_POSITION` + ], + [ + 'foreignRules', + `SELECT TABLE_NAME tableName,CONSTRAINT_NAME name,MATCH_OPTION matchOption,UPDATE_RULE updateRule,DELETE_RULE deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME IN (${marks}) ORDER BY TABLE_NAME,CONSTRAINT_NAME` + ], + [ + 'checks', + `SELECT t.TABLE_NAME tableName,t.CONSTRAINT_NAME name,t.ENFORCED enforced,${bound('c.CHECK_CLAUSE')} clause FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME IN (${marks}) AND t.CONSTRAINT_TYPE='CHECK' ORDER BY t.TABLE_NAME,t.CONSTRAINT_NAME` + ], + [ + 'triggers', + `SELECT EVENT_OBJECT_TABLE tableName,TRIGGER_NAME name,EVENT_MANIPULATION event,ACTION_TIMING timing,ACTION_ORDER actionOrder,DEFINER definer,${bound('ACTION_STATEMENT')} body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION connectionCollation,DATABASE_COLLATION databaseCollation FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE IN (${marks}) AND TRIGGER_NAME NOT IN (${journalNames.map(() => '?').join(',')}) ORDER BY EVENT_OBJECT_TABLE,TRIGGER_NAME` + ] + ] as const + const digest = createHash('sha256').update('snapshot-journal-mysql-binding-v1\n') + let bytes = 0 + await runInSeries(definitions, async ([kind, sql]) => { + const [rows]: Array>> = await k.raw( + sql + ' LIMIT 513', + kind === 'triggers' ? [...tables, ...journalNames] : tables + ) + if ( + !Array.isArray(rows) || + rows.length > 512 || + rows.some(row => + Object.values(row).some(value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') > 16384) + ) + ) + invalid() + if ( + kind === 'tables' && + (rows.length !== tables.length || rows.some(row => row.type !== 'BASE TABLE' || row.engine !== 'InnoDB')) + ) + invalid() + const canonical = JSON.stringify([kind, rows]) + bytes += Buffer.byteLength(canonical, 'utf8') + if (bytes > 1048576) invalid() + digest.update(canonical) + }) + return digest.digest('hex') +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPage.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPage.ts new file mode 100644 index 000000000..68b99b7e0 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPage.ts @@ -0,0 +1,199 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { + compareSnapshotJournalRevisions, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' +import { snapshotJournalRevisionOperand, snapshotJournalRevisionText } from './SnapshotJournalRevisionSql' + +export interface SnapshotJournalPosition { + revision: SnapshotJournalRevision + id1: number + id2: number + exactText: string +} + +export interface SnapshotJournalInterval { + stream: 'scope' | 'physical' + tableId: number + userId: number + floor: SnapshotJournalRevision + low: SnapshotJournalRevision + high: SnapshotJournalRevision + after?: SnapshotJournalPosition + limit: number +} + +export interface SnapshotJournalMetadata extends SnapshotJournalPosition { + present: boolean + generation?: SnapshotJournalRevision +} + +function invalid(): never { + throw new WERR_INVALID_OPERATION('Invalid snapshot journal interval or metadata') +} + +function integer(value: unknown, minimum: number, maximum = Number.MAX_SAFE_INTEGER): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) invalid() + return value +} + +/** Source keys are exact UTF-8 bytes, independent of the source's text collation. */ +function textKey(value: unknown): string { + const text = value instanceof Uint8Array ? Buffer.from(value).toString('utf8') : value + if (typeof text !== 'string') invalid() + const bytes = Buffer.from(text, 'utf8') + if (bytes.byteLength > 400 || bytes.toString('utf8') !== text) invalid() + if (value instanceof Uint8Array && !bytes.equals(Buffer.from(value))) invalid() + return text +} + +function sqlite(k: Knex): boolean { + const client = k.client.config.client + if (client === 'sqlite3' || client === 'better-sqlite3') return true + if (client === 'mysql' || client === 'mysql2') return false + throw new WERR_INVALID_OPERATION('Unsupported snapshot journal SQL driver') +} + +function compare(left: SnapshotJournalPosition, right: SnapshotJournalPosition): number { + return ( + compareSnapshotJournalRevisions(left.revision, right.revision) || + left.id1 - right.id1 || + left.id2 - right.id2 || + Buffer.compare(Buffer.from(left.exactText, 'utf8'), Buffer.from(right.exactText, 'utf8')) + ) +} + +function validate(interval: SnapshotJournalInterval): void { + const { stream, tableId, userId, floor, low, high, after, limit } = interval + if (stream !== 'scope' && stream !== 'physical') invalid() + integer(tableId, 0, 12) + integer(userId, 1) + integer(limit, 1, 256) + for (const value of [floor, low, high]) snapshotJournalRevision(value) + if (compareSnapshotJournalRevisions(floor, low) > 0 || compareSnapshotJournalRevisions(low, high) > 0) invalid() + if (stream === 'physical' && tableId !== 8 && tableId !== 9) invalid() + if (after !== undefined) { + snapshotJournalRevision(after.revision) + if ( + compareSnapshotJournalRevisions(after.revision, low) <= 0 || + compareSnapshotJournalRevisions(after.revision, high) > 0 + ) + invalid() + integer(after.id1, 1) + integer(after.id2, 0) + if (typeof after.exactText !== 'string') invalid() + textKey(after.exactText) + } +} + +/** Caller supplies the same pinned connection used for floor, high-water and payload reads. */ +export function snapshotJournalMetadataQuery(k: Knex, interval: SnapshotJournalInterval): Knex.QueryBuilder { + validate(interval) + const local = sqlite(k) + const table = 'snapshot_journal_' + interval.stream + const query = k + .from(k.raw(local ? '?? AS ?? INDEXED BY ??' : '?? AS ?? FORCE INDEX (??)', [table, 'j', table + '_page'])) + .select('j.id1', 'j.id2', 'j.exactText', 'j.present') + .select({ revisionText: snapshotJournalRevisionText(k, 'j.revision') }) + .where('j.tableId', interval.tableId) + .where('j.revision', '<=', snapshotJournalRevisionOperand(k, interval.high)) + .orderBy(['j.revision', 'j.id1', 'j.id2', 'j.exactText']) + .limit(interval.limit) + if (interval.stream === 'scope') query.where('j.userId', interval.userId) + else query.select({ generationText: snapshotJournalRevisionText(k, 'j.generation') }) + const after = interval.after + if (after === undefined) query.where('j.revision', '>', snapshotJournalRevisionOperand(k, interval.low)) + else { + const revision = snapshotJournalRevisionOperand(k, after.revision) + if (local) { + query.whereRaw('(??,??,??,??) > (?,?,?,?)', [ + 'j.revision', + 'j.id1', + 'j.id2', + 'j.exactText', + revision, + after.id1, + after.id2, + after.exactText + ]) + } else { + query.where('j.revision', '>=', revision).where(function () { + this.where('j.revision', '>', revision) + .orWhere(function () { + this.where('j.revision', revision).where('j.id1', '>', after.id1) + }) + .orWhere(function () { + this.where('j.revision', revision).where('j.id1', after.id1).where('j.id2', '>', after.id2) + }) + .orWhere(function () { + this.where('j.revision', revision) + .where('j.id1', after.id1) + .where('j.id2', after.id2) + .where('j.exactText', '>', Buffer.from(after.exactText, 'utf8')) + }) + }) + } + } + return query +} + +/** The bound applies before membership filtering, including unrelated global changes. */ +export async function readSnapshotJournalMetadataPage( + k: Knex, + interval: SnapshotJournalInterval +): Promise<{ + rows: SnapshotJournalMetadata[] + examined: number + complete: boolean + after?: SnapshotJournalPosition +}> { + const query = snapshotJournalMetadataQuery(k, interval) + if (interval.low === interval.high) return { rows: [], examined: 0, complete: true } + const records: Array> = await query + if (records.length > interval.limit) invalid() + const rows = records.map(record => { + const revision = snapshotJournalRevision(record.revisionText) + if ( + compareSnapshotJournalRevisions(revision, interval.low) <= 0 || + compareSnapshotJournalRevisions(revision, interval.high) > 0 + ) + invalid() + if (![0, 1, false, true].includes(record.present as number | boolean)) invalid() + const row: SnapshotJournalMetadata = { + revision, + id1: integer(record.id1, 1), + id2: integer(record.id2, 0), + exactText: textKey(record.exactText), + present: record.present === 1 || record.present === true + } + if (interval.stream === 'physical') { + const generation = snapshotJournalRevision(record.generationText) + if (generation === '0' || compareSnapshotJournalRevisions(generation, revision) > 0) invalid() + row.generation = generation + } + return row + }) + let previous = interval.after + for (const row of rows) { + if (previous !== undefined && compare(previous, row) >= 0) invalid() + previous = row + } + const last = rows.at(-1) + return { + rows, + examined: rows.length, + complete: rows.length < interval.limit, + ...(last === undefined + ? {} + : { + after: { + revision: last.revision, + id1: last.id1, + id2: last.id2, + exactText: last.exactText + } + }) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts new file mode 100644 index 000000000..27faba7fb --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts @@ -0,0 +1,52 @@ +import { + readSnapshotJournalSqliteGeneration, + completeSnapshotJournalSqliteGeneration +} from './SnapshotJournalSqliteGeneration' +import { knex, type Knex } from 'knex' +import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' +import { readSnapshotJournalHighWater } from './SnapshotJournalHighWater' +import { snapshotJournalSqliteObserverSql } from './SnapshotJournalSqliteObservers' +import { snapshotJournalMysqlObserverSql } from './SnapshotJournalMysqlObservers' +import { snapshotJournalRevision as rev } from './SnapshotJournalRevision' + +test.each([ + ['bootstrap', (k: Knex) => copySnapshotJournalBootstrapPage(k)], + ['SQLite generation read', (k: Knex) => readSnapshotJournalSqliteGeneration(k)], + ['SQLite generation completion', (k: Knex) => completeSnapshotJournalSqliteGeneration(k)], + ['high-water', (k: Knex) => readSnapshotJournalHighWater(k, 1, rev('0'), rev('0'))], + ['SQLite observers', (k: Knex) => snapshotJournalSqliteObserverSql(k)], + ['MySQL observers', (k: Knex) => snapshotJournalMysqlObserverSql(k)] +] as const)('%s rejects an unsupported driver before I/O', async (_name, run) => { + const k = knex({ client: 'mysql2' }), + query = jest.fn() + k.client.config.client = 'unsupported' + k.on('query', query) + try { + await expect(run(k)).rejects.toThrow() + expect(query).not.toHaveBeenCalled() + } finally { + await k.destroy() + } +}) +test('SQLite observer preparation refuses a missing source without any schema writes', async () => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }), + queries: string[] = [] + k.on('query', q => queries.push(q.sql)) + try { + await expect(snapshotJournalSqliteObserverSql(k)).rejects.toThrow('Missing snapshot journal source') + expect(queries).toEqual(['PRAGMA table_info(`transactions`)']) + expect(await k('sqlite_master').select('name')).toEqual([]) + } finally { + await k.destroy() + } +}) +test('MySQL observer preparation refuses missing driver metadata without emitting DDL', async () => { + const raw = jest.fn(async () => [[]]), + k = { client: { config: { client: 'mysql2' } }, raw } as unknown as Knex + await expect(snapshotJournalMysqlObserverSql(k)).rejects.toThrow('Missing snapshot journal source') + expect(raw).toHaveBeenCalledTimes(1) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevision.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevision.ts new file mode 100644 index 000000000..6bf9c8417 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevision.ts @@ -0,0 +1,29 @@ +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' + +/** Common nonnegative INTEGER range for SQLite and MySQL journal positions. */ +export const MAX_SNAPSHOT_JOURNAL_REVISION = '9223372036854775807' + +declare const journalRevision: unique symbol +export type SnapshotJournalRevision = string & { readonly [journalRevision]: true } + +/** Canonical decimal strings preserve exact persisted and transported positions. */ +export function snapshotJournalRevision(value: unknown): SnapshotJournalRevision { + if ( + typeof value !== 'string' || + !/^(?:0|[1-9][0-9]{0,18})$/.test(value) || + (value.length === 19 && value > MAX_SNAPSHOT_JOURNAL_REVISION) + ) { + throw new WERR_INVALID_OPERATION('Invalid snapshot journal revision') + } + return value as SnapshotJournalRevision +} + +/** The caller must validate both values at each persisted or transport boundary. */ +export function compareSnapshotJournalRevisions( + left: SnapshotJournalRevision, + right: SnapshotJournalRevision +): -1 | 0 | 1 { + if (left === right) return 0 + if (left.length !== right.length) return left.length < right.length ? -1 : 1 + return left < right ? -1 : 1 +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.test.ts new file mode 100644 index 000000000..ac055c547 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.test.ts @@ -0,0 +1,64 @@ +import { knex, type Knex } from 'knex' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' +import { snapshotJournalRevisionText, snapshotJournalRevisionOperand } from './SnapshotJournalRevisionSql' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' + +test('the SQL helpers preserve exact SQLite range and generation values', async () => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + try { + await k.raw('CREATE TABLE revisions(revision INTEGER PRIMARY KEY,generation INTEGER NOT NULL)') + const values = ['9', '10', '9007199254740992', '9007199254740993', '9223372036854775807'] + for (const revision of values) await k('revisions').insert({ revision, generation: revision }) + const query = k('revisions') + .select({ + revisionText: snapshotJournalRevisionText(k, 'revisions.revision'), + generationText: snapshotJournalRevisionText(k, 'revisions.generation') + }) + .where('revision', '>', snapshotJournalRevisionOperand(k, snapshotJournalRevision('9007199254740992'))) + .orderBy('revision') + .limit(2) + expect(await query).toEqual(values.slice(3).map(value => ({ revisionText: value, generationText: value }))) + const sql = query.toSQL(), + plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + sql.sql, sql.bindings) + expect(plan.some(row => row.detail.includes('SEARCH revisions USING INTEGER PRIMARY KEY'))).toBe(true) + expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false) + expect(() => snapshotJournalRevisionOperand(k, '9223372036854775808' as SnapshotJournalRevision)).toThrow( + WERR_INVALID_OPERATION + ) + expect(() => snapshotJournalRevisionOperand(k, '1 OR 1=1' as SnapshotJournalRevision)).toThrow( + WERR_INVALID_OPERATION + ) + } finally { + await k.destroy() + } +}) + +test('MySQL operands and selected text use integer-preserving types and parameter bindings', async () => { + const k = knex({ client: 'mysql2' }) + try { + const query = k('revisions') + .select({ exactRevision: snapshotJournalRevisionText(k, 'revision') }) + .where('revision', '>', snapshotJournalRevisionOperand(k, snapshotJournalRevision('9007199254740992'))) + .orderBy('revision') + .limit(2) + .toSQL() + expect(query.sql).toContain('CAST(`revision` AS CHAR)') + expect(query.sql).toContain('CAST(? AS SIGNED)') + expect(query.bindings).toEqual(['9007199254740992', 2]) + } finally { + await k.destroy() + } +}) + +test.each(['pg', 'mssql', 'mysql-custom', undefined])('unknown driver %p refuses before issuing SQL', client => { + const k = { client: { config: { client } } } as unknown as Knex + expect(() => snapshotJournalRevisionText(k, 'revision')).toThrow('Unsupported snapshot journal SQL driver') + expect(() => snapshotJournalRevisionOperand(k, snapshotJournalRevision('0'))).toThrow( + 'Unsupported snapshot journal SQL driver' + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.ts new file mode 100644 index 000000000..40aacd89f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.ts @@ -0,0 +1,21 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' + +function sqlite(k: Knex): boolean { + const client = k.client.config.client + if (client === 'better-sqlite3' || client === 'sqlite3') return true + if (client === 'mysql' || client === 'mysql2') return false + throw new WERR_INVALID_OPERATION('Unsupported snapshot journal SQL driver') +} + +/** Select with an alias different from the integer column used by ORDER BY. */ +export function snapshotJournalRevisionText(k: Knex, column: string): Knex.Raw { + return k.raw(sqlite(k) ? 'CAST(?? AS TEXT)' : 'CAST(?? AS CHAR)', [column]) +} + +/** Explicit integer operands avoid implicit floating-point comparisons. */ +export function snapshotJournalRevisionOperand(k: Knex, value: SnapshotJournalRevision): Knex.Raw { + const revision = snapshotJournalRevision(value) + return k.raw(sqlite(k) ? 'CAST(? AS INTEGER)' : 'CAST(? AS SIGNED)', [revision]) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.test.ts new file mode 100644 index 000000000..ec5373077 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.test.ts @@ -0,0 +1,204 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + MAX_SNAPSHOT_JOURNAL_REVISION, + snapshotJournalRevision as rev, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' +import { snapshotJournalRevisionText } from './SnapshotJournalRevisionSql' +import { + installSnapshotJournalSqliteClock, + SNAPSHOT_JOURNAL_SQLITE_ADVANCE, + SNAPSHOT_JOURNAL_SQLITE_WRITABLE, + SNAPSHOT_JOURNAL_SQLITE_REVISION +} from './SnapshotJournalSqliteClock' + +function open(filename = ':memory:'): Knex { + return knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) +} + +async function install(k: Knex, ceiling: string): Promise { + await installSnapshotJournalSqliteClock(k, rev(ceiling)) + await k.raw('CREATE TABLE ordinary_rows(id INTEGER PRIMARY KEY,payload TEXT)') + await k.raw('CREATE TABLE journal_rows(id INTEGER PRIMARY KEY,revision INTEGER)') + await k.raw(`CREATE TRIGGER observe_ordinary_row AFTER INSERT ON ordinary_rows BEGIN + ${SNAPSHOT_JOURNAL_SQLITE_ADVANCE} + INSERT INTO journal_rows(id,revision) SELECT NEW.id,${SNAPSHOT_JOURNAL_SQLITE_REVISION} WHERE ${SNAPSHOT_JOURNAL_SQLITE_WRITABLE}; + END`) +} + +test('event-window exhaustion invalidates continuity while all ordinary rows commit', async () => { + const k = open() + try { + await install(k, '2') + for (const id of [1, 2, 3, 4]) await k('ordinary_rows').insert({ id, payload: 'preserve-' + id }) + expect(await k('ordinary_rows').orderBy('id')).toEqual([1, 2, 3, 4].map(id => ({ id, payload: 'preserve-' + id }))) + expect(await k('journal_rows').orderBy('id')).toEqual([ + { id: 1, revision: 1 }, + { id: 2, revision: 2 } + ]) + expect(await k('snapshot_journal_clock').first()).toEqual({ + id: 1, + revision: 2, + ceiling: 2, + enabled: 0, + reason: 'capacity-exhausted' + }) + } finally { + await k.destroy() + } +}) + +test('revision exhaustion never converts the clock to floating point or rejects the ordinary write', async () => { + const k = open() + try { + await install(k, MAX_SNAPSHOT_JOURNAL_REVISION) + await k('snapshot_journal_clock').update({ + revision: String(BigInt(MAX_SNAPSHOT_JOURNAL_REVISION) - 1n) + }) + await k('ordinary_rows').insert({ id: 1, payload: 'last representable journal event' }) + expect(await k('journal_rows').select({ revision: snapshotJournalRevisionText(k, 'revision') })).toEqual([ + { revision: MAX_SNAPSHOT_JOURNAL_REVISION } + ]) + await k('ordinary_rows').insert({ id: 2, payload: 'preserved after journal exhaustion' }) + expect(await k('ordinary_rows').orderBy('id').pluck('id')).toEqual([1, 2]) + expect(await k('snapshot_journal_clock').select('enabled', 'reason').first()).toEqual({ + enabled: 0, + reason: 'revision-exhausted' + }) + expect( + await k.raw('SELECT typeof(revision) kind,CAST(revision AS TEXT) revision FROM snapshot_journal_clock') + ).toEqual([{ kind: 'integer', revision: MAX_SNAPSHOT_JOURNAL_REVISION }]) + } finally { + await k.destroy() + } +}) + +test('rollback restores the source, journal and continuity flag together', async () => { + const k = open() + try { + await install(k, '1') + await k('ordinary_rows').insert({ id: 1, payload: 'committed' }) + const failure = new Error('rollback source transaction') + await expect( + k.transaction(async transaction => { + await transaction('ordinary_rows').insert({ id: 2, payload: 'rolled back' }) + expect(await transaction('snapshot_journal_clock').first('enabled')).toEqual({ enabled: 0 }) + throw failure + }) + ).rejects.toBe(failure) + expect(await k('snapshot_journal_clock').first()).toEqual({ + id: 1, + revision: 1, + ceiling: 1, + enabled: 1, + reason: null + }) + expect(await k('ordinary_rows')).toEqual([{ id: 1, payload: 'committed' }]) + expect(await k('journal_rows')).toEqual([{ id: 1, revision: 1 }]) + } finally { + await k.destroy() + } +}) + +test('an already pinned WAL view remains coherent across continuity invalidation', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ts569-journal-clock-')) + const k = open(join(directory, 'clock.sqlite')), + writer = open(join(directory, 'clock.sqlite')) + let view: Knex.Transaction | undefined + try { + await k.raw('PRAGMA journal_mode=WAL') + await install(k, '1') + await k('ordinary_rows').insert({ id: 1, payload: 'before pin' }) + view = await k.transaction() + expect(await view('snapshot_journal_clock').first('enabled')).toEqual({ enabled: 1 }) + await writer('ordinary_rows').insert({ id: 2, payload: 'after pin' }) + expect(await writer('snapshot_journal_clock').first('enabled')).toEqual({ enabled: 0 }) + expect(await view('snapshot_journal_clock').first('enabled')).toEqual({ enabled: 1 }) + expect(await view('ordinary_rows')).toEqual([{ id: 1, payload: 'before pin' }]) + expect(await view('journal_rows')).toEqual([{ id: 1, revision: 1 }]) + } finally { + await view?.rollback() + await k.destroy() + await writer.destroy() + await rm(directory, { recursive: true, force: true }) + } +}) + +test.each(['0', '01', '-1', '9223372036854775808'])( + 'invalid event ceiling %s refuses before installation', + async ceiling => { + const k = open() + try { + await expect(installSnapshotJournalSqliteClock(k, ceiling as SnapshotJournalRevision)).rejects.toThrow() + expect(await k.schema.hasTable('snapshot_journal_clock')).toBe(false) + } finally { + await k.destroy() + } + } +) + +test('new installation refuses to adopt or reset an existing clock', async () => { + const k = open() + try { + await install(k, '1') + await k('ordinary_rows').insert({ id: 1 }) + await expect(installSnapshotJournalSqliteClock(k, rev('10'))).rejects.toThrow() + expect(await k('snapshot_journal_clock').first()).toEqual({ + id: 1, + revision: 1, + ceiling: 1, + enabled: 1, + reason: null + }) + } finally { + await k.destroy() + } +}) + +test('the SQLite clock refuses a MySQL connection before issuing SQL', async () => { + const k = knex({ client: 'mysql2' }) + const seen = jest.fn() + k.on('query', seen) + try { + await expect(installSnapshotJournalSqliteClock(k, rev('1'))).rejects.toThrow( + 'Snapshot journal clock requires SQLite' + ) + expect(seen).not.toHaveBeenCalled() + } finally { + await k.destroy() + } +}) + +test('native clock constraints refuse invalid continuity states atomically', async () => { + const k = open() + try { + await install(k, '1') + for (const change of [ + { enabled: 0, reason: null }, + { enabled: 1, reason: 'capacity-exhausted' }, + { enabled: 0, reason: 'unknown' }, + { revision: 2 }, + { revision: -1 }, + { revision: 0.5 } + ]) { + await expect(k('snapshot_journal_clock').update(change)).rejects.toThrow() + expect(await k('snapshot_journal_clock').first()).toEqual({ + id: 1, + revision: 0, + ceiling: 1, + enabled: 1, + reason: null + }) + } + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.ts new file mode 100644 index 000000000..5639742dc --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.ts @@ -0,0 +1,46 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { + MAX_SNAPSHOT_JOURNAL_REVISION, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' + +export const SNAPSHOT_JOURNAL_SQLITE_CLOCK = 'snapshot_journal_clock' + +export const SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL = `CREATE TABLE snapshot_journal_clock ( +id INTEGER NOT NULL PRIMARY KEY CHECK(id=1), +revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=0), +ceiling INTEGER NOT NULL CHECK(typeof(ceiling)='integer' AND ceiling>=revision), +enabled INTEGER NOT NULL CHECK(enabled IN (0,1)), +reason TEXT CHECK((enabled=1 AND reason IS NULL) OR (enabled=0 AND reason IS NOT NULL AND reason IN ('capacity-exhausted','revision-exhausted','key-out-of-range'))) +)` + +/** Advance only within the reserved event window; the source write may still commit. */ +export const SNAPSHOT_JOURNAL_SQLITE_ADVANCE = `UPDATE snapshot_journal_clock SET +revision=CASE WHEN revision=ceiling THEN 0 ELSE enabled END, +reason=CASE WHEN revision>=ceiling THEN CASE WHEN revision=${MAX_SNAPSHOT_JOURNAL_REVISION} THEN 'revision-exhausted' ELSE 'capacity-exhausted' END ELSE reason END +WHERE id=1 AND enabled=1; ` + +/** Guard every metadata write after advancing: the current event may invalidate the epoch. */ +export const SNAPSHOT_JOURNAL_SQLITE_WRITABLE = '(SELECT enabled FROM snapshot_journal_clock WHERE id=1)=1' +export const SNAPSHOT_JOURNAL_SQLITE_REVISION = '(SELECT revision FROM snapshot_journal_clock WHERE id=1)' + +/** New owned installation only. Recovery and epoch replacement belong to the migration. */ +export async function installSnapshotJournalSqliteClock(k: Knex, ceiling: SnapshotJournalRevision): Promise { + if (k.client.config.client !== 'sqlite3' && k.client.config.client !== 'better-sqlite3') { + throw new WERR_INVALID_OPERATION('Snapshot journal clock requires SQLite') + } + if (snapshotJournalRevision(ceiling) === '0') throw new WERR_INVALID_OPERATION('Empty snapshot journal event window') + await k.transaction(async transaction => { + await transaction.raw(SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL) + await transaction(SNAPSHOT_JOURNAL_SQLITE_CLOCK).insert({ + id: 1, + revision: 0, + ceiling, + enabled: 1, + reason: null + }) + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts new file mode 100644 index 000000000..ec176be61 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts @@ -0,0 +1,300 @@ +import * as observers from './SnapshotJournalSqliteObservers' +import { knex, type Knex } from 'knex' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' +import { + installSnapshotJournalSqliteGeneration, + readSnapshotJournalSqliteGeneration, + completeSnapshotJournalSqliteGeneration, + SNAPSHOT_JOURNAL_SQLITE_GENERATION as metadata +} from './SnapshotJournalSqliteGeneration' +import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' +import { snapshotJournalRevision } from './SnapshotJournalRevision' +const ceiling = snapshotJournalRevision('1000000') +async function fixture() { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + const source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + try { + await source.migrate('journal lifecycle', 'synthetic-journal-lifecycle') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)), + { user: foreign } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, foreign.userId) + return { k, source } + } catch (error) { + await source.destroy() + throw error + } +} +async function finish(k: Knex) { + for (let page = 0; page < 80; page++) if ((await copySnapshotJournalBootstrapPage(k)).complete) return + throw new Error('bootstrap did not finish') +} + +test('atomic installation resumes its epoch and publishes only completed durable bootstrap', async () => { + const { k, source } = await fixture() + try { + const rows = await k('transactions'), + installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + expect(installed).toMatchObject({ complete: false, enabled: true, ceiling }) + expect(installed.epoch).toMatch(/^[0-9a-f-]{36}$/) + await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') + await copySnapshotJournalBootstrapPage(k) + const progress = await k('snapshot_journal_bootstrap').first() + expect(await installSnapshotJournalSqliteGeneration(k, ceiling)).toEqual(installed) + expect(await k('snapshot_journal_bootstrap').first()).toEqual(progress) + await finish(k) + expect(await readSnapshotJournalSqliteGeneration(k)).toEqual(installed) + expect(await completeSnapshotJournalSqliteGeneration(k)).toEqual({ + ...installed, + complete: true + }) + expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, complete: true }) + expect(await k('transactions')).toEqual(rows) + } finally { + await source.destroy() + } +}) +test.each([ + 'CREATE TABLE snapshot_journal_clock(id INTEGER)', + 'CREATE VIEW snapshot_journal_scope_0_INSERT AS SELECT 1', + 'CREATE INDEX snapshot_journal_foreign ON users(identityKey)', + "CREATE TRIGGER snapshot_journal_foreign AFTER INSERT ON users BEGIN SELECT 'foreign'; END" +])('first installation refuses unrelated reserved objects: %s', async ddl => { + const { k, source } = await fixture() + try { + await k.raw(ddl) + const before = await k('sqlite_master').orderBy(['type', 'name']) + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(await k('sqlite_master').orderBy(['type', 'name'])).toEqual(before) + } finally { + await source.destroy() + } +}) +test.each(['observer', 'index', 'same-name view', 'unknown reserved'])( + 'resume refuses %s ownership drift without source mutation', + async kind => { + const { k, source } = await fixture() + try { + await installSnapshotJournalSqliteGeneration(k, ceiling) + if (kind === 'observer') await k.raw('DROP TRIGGER snapshot_journal_scope_0_INSERT') + if (kind === 'index') await k.raw('DROP INDEX snapshot_journal_scope_page') + if (kind === 'same-name view') await k.raw('CREATE VIEW snapshot_journal_scope_0_INSERT AS SELECT 1') + if (kind === 'unknown reserved') await k.raw('CREATE TABLE snapshot_journal_foreign(id INTEGER)') + const before = await k('sqlite_master').orderBy(['type', 'name']), + rows = await k('transactions') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(await k('sqlite_master').orderBy(['type', 'name'])).toEqual(before) + expect(await k('transactions')).toEqual(rows) + } finally { + await source.destroy() + } + } +) +test('complete source binding includes user observers and preserves literal whitespace', async () => { + const { k, source } = await fixture() + try { + await k.raw("CREATE TRIGGER application_user AFTER INSERT ON users BEGIN SELECT 'a b'; END") + await installSnapshotJournalSqliteGeneration(k, ceiling) + await k.raw('DROP TRIGGER application_user') + await k.raw("CREATE TRIGGER application_user AFTER INSERT ON users BEGIN SELECT 'a b'; END") + await expect(readSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') + } finally { + await source.destroy() + } +}) +test.each([ + 'metadata missing', + 'metadata epoch', + 'metadata source', + 'metadata ceiling', + 'clock missing', + 'clock ceiling', + 'bootstrap missing', + 'bootstrap extra', + 'bootstrap cursor', + 'false completion' +])('persisted %s damage refuses', async kind => { + const { k, source } = await fixture() + try { + await installSnapshotJournalSqliteGeneration(k, ceiling) + if (kind === 'metadata missing') await k(metadata).delete() + if (kind === 'metadata epoch') await k(metadata).update({ epoch: 'foreign' }) + if (kind === 'metadata source') await k(metadata).update({ source: '0'.repeat(64) }) + if (kind === 'metadata ceiling') await k(metadata).update({ ceiling: '01' }) + if (kind === 'clock missing') await k('snapshot_journal_clock').delete() + if (kind === 'clock ceiling') await k('snapshot_journal_clock').update({ ceiling: 999 }) + if (kind === 'bootstrap missing') await k('snapshot_journal_bootstrap').delete() + if (kind === 'bootstrap extra') await k('snapshot_journal_bootstrap').update({ stream: 0.5 }) + if (kind === 'bootstrap cursor') await k('snapshot_journal_bootstrap').update({ cursor: 'x'.repeat(2049) }) + if (kind === 'false completion') await k(metadata).update({ complete: 1 }) + await expect(readSnapshotJournalSqliteGeneration(k)).rejects.toThrow() + } finally { + await source.destroy() + } +}) +test('configured event exhaustion preserves source writes and refuses completion', async () => { + const { k, source } = await fixture() + try { + const installed = await installSnapshotJournalSqliteGeneration(k, snapshotJournalRevision('1')) + await k('tx_labels').where('txLabelId', 1).update({ label: 'first' }) + await k('tx_labels').where('txLabelId', 1).update({ label: 'ordinary after exhaustion' }) + expect((await k('tx_labels').where('txLabelId', 1).first()).label).toBe('ordinary after exhaustion') + expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, enabled: false }) + await k('snapshot_journal_bootstrap').update({ stream: 17, cursor: null }) + await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await source.destroy() + } +}) +test.each([ + 'CREATE TABLE snapshot_journal_scope', + 'CREATE TRIGGER snapshot_journal_physical_12_DELETE', + 'insert into `snapshot_journal_generation`' +])('failure during %s rolls back every installation object', async point => { + const { k, source } = await fixture() + try { + const before = await k('sqlite_master').orderBy(['type', 'name']), + failure = new Error('synthetic installation failure') + const listener = (query: { sql: string }) => { + if (query.sql.startsWith(point)) throw failure + } + k.on('query', listener) + try { + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toBe(failure) + } finally { + k.off('query', listener) + } + expect(await k('sqlite_master').orderBy(['type', 'name'])).toEqual(before) + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).resolves.toMatchObject({ + complete: false, + enabled: true + }) + } finally { + await source.destroy() + } +}) +test('completion rollback retains the unfinished generation and can resume', async () => { + const { k, source } = await fixture() + try { + const installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + await finish(k) + const failure = new Error('synthetic completion failure'), + listener = (query: { sql: string }) => { + if (query.sql.startsWith('update `snapshot_journal_generation`')) throw failure + } + k.on('query', listener) + try { + await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toBe(failure) + } finally { + k.off('query', listener) + } + expect(await readSnapshotJournalSqliteGeneration(k)).toEqual(installed) + await expect(completeSnapshotJournalSqliteGeneration(k)).resolves.toMatchObject({ + complete: true + }) + } finally { + await source.destroy() + } +}) +test('unsupported client and missing published SQLite prerequisites refuse before installation', async () => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + try { + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(await k('sqlite_master')).toEqual([]) + k.client.config.client = 'mysql2' + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + } finally { + await k.destroy() + } +}) + +test.each(['zero ceiling', 'invalid clock reason', 'replaced observer'])( + 'owned generation refuses incompatible persisted %s', + async kind => { + const { k, source } = await fixture() + try { + await installSnapshotJournalSqliteGeneration(k, ceiling) + if (kind === 'zero ceiling') await k(metadata).update({ ceiling: '0' }) + if (kind === 'invalid clock reason') { + await k.raw('PRAGMA ignore_check_constraints=ON') + await k('snapshot_journal_clock').update({ reason: 'unexpected' }) + await k.raw('PRAGMA ignore_check_constraints=OFF') + } + if (kind === 'replaced observer') { + await k.raw('DROP TRIGGER snapshot_journal_scope_0_INSERT') + await k.raw('CREATE TRIGGER snapshot_journal_scope_0_INSERT AFTER INSERT ON transactions BEGIN SELECT 1; END') + } + const before = await k('transactions') + await expect(readSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') + expect(await k('transactions')).toEqual(before) + } finally { + await source.destroy() + } + } +) +test.each(['rows', 'definition', 'aggregate'])('source binding refuses an oversized native schema %s', async kind => { + const { k, source } = await fixture() + try { + if (kind === 'rows') { + for (let i = 0; i < 513; i++) await k.raw('CREATE INDEX ?? ON users(identityKey)', ['application_index_' + i]) + } else { + const count = kind === 'aggregate' ? 20 : 1 + const literal = 'x'.repeat(kind === 'aggregate' ? 60000 : 65536) + for (let i = 0; i < count; i++) + await k.raw( + 'CREATE TRIGGER application_observer_' + i + " AFTER UPDATE ON users BEGIN SELECT '" + literal + "'; END" + ) + } + const before = await k('sqlite_master').select('type', 'name', 'sql').orderBy(['type', 'name']) + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(await k('sqlite_master').select('type', 'name', 'sql').orderBy(['type', 'name'])).toEqual(before) + } finally { + await source.destroy() + } +}) + +test('invalid generated DDL cannot enter the persisted SQLite ownership plan', async () => { + const { k, source } = await fixture() + const spy = jest + .spyOn(observers, 'snapshotJournalSqliteObserverSql') + .mockResolvedValue(['CREATE TABLE application_table(id INTEGER)']) + try { + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(await k('sqlite_master').where('name', 'application_table')).toEqual([]) + } finally { + spy.mockRestore() + await source.destroy() + } +}) +test.each(['users', 'settings'])('source metadata must include %s in the ownership binding', async table => { + const { k, source } = await fixture() + let altered = false + const listener = (rows: Array<{ type: string; name: string }>, query: { sql: string }) => { + if (query.sql.includes('substr(sql,1,65537) AS sql') && query.sql.includes('where `tbl_name` in')) { + const index = rows.findIndex(row => row.type === 'table' && row.name === table) + expect(index).toBeGreaterThanOrEqual(0) + rows.splice(index, 1) + altered = true + } + } + k.on('query-response', listener) + try { + await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + expect(altered).toBe(true) + } finally { + k.off('query-response', listener) + await source.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts new file mode 100644 index 000000000..9b68040e5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts @@ -0,0 +1,235 @@ +import type { Knex } from 'knex' +import { createHash, randomUUID } from 'node:crypto' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { numeric } from '../../schema/snapshotSqliteMembership' +import { names, metadata as indexMetadata } from '../../schema/snapshotSqliteIndexGeneration' +import { readGenerationIndexState } from '../../schema/snapshotSqliteIndexState' +import { SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL } from './SnapshotJournalSqliteClock' +import { + snapshotJournalSqliteObserverSql, + SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL +} from './SnapshotJournalSqliteObservers' +import { SNAPSHOT_JOURNAL_BOOTSTRAP_DDL } from './SnapshotJournalBootstrap' +import { + snapshotJournalRevision, + compareSnapshotJournalRevisions, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' + +export const SNAPSHOT_JOURNAL_SQLITE_GENERATION = 'snapshot_journal_generation' +export const SNAPSHOT_JOURNAL_SQLITE_GENERATION_DDL = + 'CREATE TABLE snapshot_journal_generation(id INTEGER NOT NULL PRIMARY KEY CHECK(id=1),version INTEGER NOT NULL CHECK(version=1),epoch TEXT NOT NULL,source TEXT NOT NULL,ceiling TEXT NOT NULL,complete INTEGER NOT NULL CHECK(complete IN (0,1)))' +interface ObjectDefinition { + type: string + name: string + sql: string +} +interface SchemaObject { + type: string + name: string + tbl_name: string + sql: string | null +} +interface Plan { + source: string + objects: ObjectDefinition[] +} +export interface SnapshotJournalSqliteGeneration { + epoch: string + source: string + ceiling: SnapshotJournalRevision + complete: boolean + enabled: boolean +} +function invalid(): never { + throw new WERR_INVALID_OPERATION('Invalid or unowned SQLite snapshot journal generation') +} +const local = (k: Knex) => k.client.config.client === 'sqlite3' || k.client.config.client === 'better-sqlite3' +async function plan(k: Knex, config?: Knex.MigratorConfig): Promise { + if (!local(k) || (await readGenerationIndexState(k, config)) !== 'v2') return invalid() + const ddl = [ + SNAPSHOT_JOURNAL_SQLITE_GENERATION_DDL, + SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL, + ...SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL, + SNAPSHOT_JOURNAL_BOOTSTRAP_DDL, + ...(await snapshotJournalSqliteObserverSql(k)) + ] + const objects = ddl.map(sql => { + const match = /^CREATE (TABLE|INDEX|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql) + if (!match) return invalid() + return { type: match[1].toLowerCase(), name: match[2], sql } + }) + const sources = [ + ...numeric.map(source => source.table), + 'tx_labels_map', + 'output_tags_map', + 'certificate_fields', + 'users', + 'settings', + ...Object.values(names) + ] + const rows: SchemaObject[] = await k('sqlite_master') + .whereIn('tbl_name', sources) + .select('type', 'name', 'tbl_name', k.raw('substr(sql,1,65537) AS sql')) + .orderBy(['type', 'name']) + .limit(513) + if (rows.length > 512 || rows.some(row => row.sql !== null && Buffer.byteLength(row.sql, 'utf8') > 65536)) + return invalid() + if (['users', 'settings'].some(table => !rows.some(row => row.type === 'table' && row.name === table))) + return invalid() + const expectedNames = new Set(objects.map(object => object.name)) + const source = JSON.stringify(rows.filter(row => !expectedNames.has(row.name))) + if (Buffer.byteLength(source, 'utf8') > 1048576) return invalid() + return { + source: createHash('sha256').update('snapshot-journal-sqlite-source-v1\n').update(source).digest('hex'), + objects + } +} +async function reserved(k: Knex): Promise { + return await k('sqlite_master') + .whereRaw('lower(substr(name,1,17))=?', ['snapshot_journal_']) + .select('type', 'name', 'tbl_name', k.raw('substr(sql,1,65537) AS sql')) + .orderBy(['type', 'name']) + .limit(513) +} +async function validate(k: Knex, p: Plan): Promise { + const actual = await reserved(k) + if (actual.length !== p.objects.length) return invalid() + for (const expected of p.objects) { + const matches = actual.filter(object => object.name === expected.name && object.type === expected.type) + if (matches.length !== 1 || matches[0].sql !== expected.sql) return invalid() + } + const rows = await k(SNAPSHOT_JOURNAL_SQLITE_GENERATION).select('*').limit(2) + if (rows.length !== 1) return invalid() + const row = rows[0] + if ( + row.id !== 1 || + row.version !== 1 || + typeof row.epoch !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(row.epoch) || + row.source !== p.source || + ![0, 1].includes(row.complete) + ) + return invalid() + const ceiling = snapshotJournalRevision(row.ceiling) + if (ceiling === '0') return invalid() + const clocks = await k('snapshot_journal_clock') + .select('id', 'enabled', 'reason', k.raw('CAST(revision AS TEXT) revision'), k.raw('CAST(ceiling AS TEXT) ceiling')) + .limit(2) + if ( + clocks.length !== 1 || + clocks[0].id !== 1 || + clocks[0].ceiling !== ceiling || + compareSnapshotJournalRevisions(snapshotJournalRevision(clocks[0].revision), ceiling) > 0 + ) + return invalid() + const clock = clocks[0] + if ( + clock.enabled === 1 + ? clock.reason !== null + : clock.enabled !== 0 || !['capacity-exhausted', 'revision-exhausted', 'key-out-of-range'].includes(clock.reason) + ) + return invalid() + const positions = await k('snapshot_journal_bootstrap').select('*').limit(2) + if (positions.length !== 1) return invalid() + const position = positions[0] + if ( + position.id !== 1 || + !Number.isInteger(position.stream) || + position.stream < 0 || + position.stream > 17 || + !( + position.cursor === null || + (typeof position.cursor === 'string' && Buffer.byteLength(position.cursor, 'utf8') <= 2048) + ) || + (position.stream === 17 && position.cursor !== null) || + (row.complete === 1 && position.stream !== 17) + ) + return invalid() + return { + epoch: row.epoch, + source: p.source, + ceiling, + complete: row.complete === 1, + enabled: clock.enabled === 1 + } +} + +/** Owned atomic SQLite installation. It does not advertise a reader or register a migration. */ +export async function installSnapshotJournalSqliteGeneration( + k: Knex, + ceiling: SnapshotJournalRevision, + config?: Knex.MigratorConfig +): Promise { + if (!local(k) || snapshotJournalRevision(ceiling) === '0') return invalid() + return await k.transaction(async t => { + if ((await readGenerationIndexState(t, config)) !== 'v2') return invalid() + await t(indexMetadata) + .where('id', 0) + .update({ complete: t.ref('complete') }) + const p = await plan(t, config), + existing = await reserved(t) + if (existing.length) { + const current = await validate(t, p) + if (current.ceiling !== ceiling) return invalid() + return current + } + await runInSeries(p.objects, async object => { + await t.raw(object.sql) + }) + const epoch = randomUUID() + await t(SNAPSHOT_JOURNAL_SQLITE_GENERATION).insert({ + id: 1, + version: 1, + epoch, + source: p.source, + ceiling, + complete: 0 + }) + await t('snapshot_journal_clock').insert({ + id: 1, + revision: 0, + ceiling, + enabled: 1, + reason: null + }) + await t('snapshot_journal_bootstrap').insert({ id: 1, stream: 0, cursor: null }) + return await validate(t, p) + }) +} + +/** Validate in the caller's pinned view; migration publication remains a separate prerequisite. */ +export async function readSnapshotJournalSqliteGeneration( + k: Knex, + config?: Knex.MigratorConfig +): Promise { + return await validate(k, await plan(k, config)) +} + +/** Atomic completion cannot be inferred from a caller's last-page acknowledgement. */ +export async function completeSnapshotJournalSqliteGeneration( + k: Knex, + config?: Knex.MigratorConfig +): Promise { + if (!local(k)) return invalid() + return await k.transaction(async t => { + await t(indexMetadata) + .where('id', 0) + .update({ complete: t.ref('complete') }) + const p = await plan(t, config), + state = await validate(t, p) + const progress = await t('snapshot_journal_bootstrap').select('*').limit(2) + const clock = await t('snapshot_journal_clock').where('id', 1).first('enabled') + if ( + progress.length !== 1 || + progress[0].id !== 1 || + progress[0].stream !== 17 || + progress[0].cursor !== null || + clock.enabled !== 1 + ) + return invalid() + await t(SNAPSHOT_JOURNAL_SQLITE_GENERATION).where('id', 1).update({ complete: 1 }) + return { ...state, complete: true } + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts new file mode 100644 index 000000000..a9389d5b3 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts @@ -0,0 +1,137 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { numeric } from '../../schema/snapshotSqliteMembership' +import { names } from '../../schema/snapshotSqliteIndexGeneration' +import { + SNAPSHOT_JOURNAL_SQLITE_ADVANCE as tick, + SNAPSHOT_JOURNAL_SQLITE_REVISION as revision, + SNAPSHOT_JOURNAL_SQLITE_WRITABLE as writable +} from './SnapshotJournalSqliteClock' + +const q = (name: string) => '"' + name.replaceAll('"', '""') + '"' +export const snapshotJournalSqliteSources = [ + ...numeric.map(source => source.table), + 'tx_labels_map', + 'output_tags_map', + 'certificate_fields' +] +const scopeKey = 'tableId,userId,id1,id2,exactText' +const physicalKey = 'tableId,id1,id2,exactText' +export const SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL = [ + `CREATE TABLE snapshot_journal_physical(tableId INTEGER NOT NULL,id1 INTEGER NOT NULL,id2 INTEGER NOT NULL,exactText TEXT COLLATE BINARY NOT NULL,revision INTEGER NOT NULL,generation INTEGER NOT NULL,present INTEGER NOT NULL,PRIMARY KEY(${physicalKey}))`, + `CREATE TABLE snapshot_journal_scope(tableId INTEGER NOT NULL,userId INTEGER NOT NULL,id1 INTEGER NOT NULL,id2 INTEGER NOT NULL,exactText TEXT COLLATE BINARY NOT NULL,revision INTEGER NOT NULL,present INTEGER NOT NULL,PRIMARY KEY(${scopeKey}))`, + 'CREATE INDEX snapshot_journal_scope_page ON snapshot_journal_scope(userId,tableId,revision,id1,id2,exactText)', + 'CREATE INDEX snapshot_journal_physical_page ON snapshot_journal_physical(tableId,revision,id1,id2,exactText)' +] +const tuple = (table: string, prefix: string) => { + const key = numeric.find(source => source.table === table)?.key + if (key) return [prefix + '.' + q(key), '0', "''"] + if (table === 'tx_labels_map') return [prefix + '.txLabelId', prefix + '.transactionId', "''"] + if (table === 'output_tags_map') return [prefix + '.outputTagId', prefix + '.outputId', "''"] + return [prefix + '.certificateId', '0', prefix + '.fieldName'] +} +const scopeUpsert = (select: string) => + `INSERT INTO snapshot_journal_scope(${scopeKey},revision,present) ${select} ON CONFLICT(${scopeKey}) DO UPDATE SET revision=excluded.revision,present=excluded.present; ` +const exactWhere = (table: string, prefix: string, alias: string) => { + const left = tuple(table, prefix), + right = tuple(table, alias) + return left.map((key, i) => `CAST(${key} AS BLOB) IS CAST(${right[i]} AS BLOB)`).join(' AND ') +} +function keyGuard(key: string[], owner?: string): string { + const [id1, id2, text] = key + const integer = (value: string, minimum: number) => + `(typeof(${value})='integer' AND ${value} BETWEEN ${minimum} AND 9007199254740991)` + const valid = [ + integer(id1, 1), + integer(id2, 0), + `(typeof(${text})='text' AND length(CAST(${text} AS BLOB))<=400)`, + ...(owner ? [integer(owner, 1)] : []) + ].join(' AND ') + return `UPDATE snapshot_journal_clock SET enabled=0,reason='key-out-of-range' WHERE id=1 AND enabled=1 AND NOT (${valid}); ` +} + +/** Prepare observers only after the caller validates the completed v2 source generation. */ +export async function snapshotJournalSqliteObserverSql(k: Knex): Promise { + if (!['sqlite3', 'better-sqlite3'].includes(k.client.config.client)) + throw new WERR_INVALID_OPERATION('Snapshot journal observers require SQLite') + const definitions: string[] = [] + const membership: Array<{ + table: string + expressions: (p: string) => string[] + present: (p: string) => string + }> = [ + { + table: names.profile, + expressions: p => [p + '.snapshotTableId', p + '.snapshotUserId', p + '.snapshotRowId', '0', "''"], + present: () => '1' + }, + { + table: names.relation, + expressions: p => [ + p + '.snapshotTableId+10', + p + '.snapshotUserId', + p + '.snapshotLeftId', + p + '.snapshotRightId', + "''" + ], + present: () => '1' + }, + { + table: names.certificate, + expressions: p => ['12', p + '.snapshotUserId', p + '.snapshotCertificateId', '0', p + '.snapshotFieldName'], + present: () => '1' + }, + { + table: names.keys, + expressions: p => ['CASE ' + p + '.tableId WHEN 0 THEN 9 ELSE 8 END', p + '.userId', p + '.rowId', '0', "''"], + present: p => p + '.present' + } + ] + for (const [i, source] of membership.entries()) + for (const event of ['INSERT', 'DELETE', 'UPDATE']) { + const p = event === 'DELETE' ? 'OLD' : 'NEW', + fields = source.expressions(p) + const selected = [...fields, revision, event === 'DELETE' ? '0' : source.present(p)].join(',') + definitions.push( + `CREATE TRIGGER snapshot_journal_scope_${i}_${event} AFTER ${event} ON ${q(source.table)} BEGIN ${keyGuard(fields.slice(2), fields[1])}${tick}${scopeUpsert('SELECT ' + selected + ' WHERE ' + writable)} END` + ) + } + await runInSeries(snapshotJournalSqliteSources.entries(), async ([tableId, table]) => { + const columns: Array<{ name: string }> = await k.raw('PRAGMA table_info(??)', [table]) + if (columns.length === 0) throw new WERR_INVALID_OPERATION('Missing snapshot journal source') + const changed = columns + .map(({ name }) => `CAST(OLD.${q(name)} AS BLOB) IS NOT CAST(NEW.${q(name)} AS BLOB)`) + .join(' OR ') + for (const event of ['INSERT', 'UPDATE', 'DELETE']) { + const prefix = event === 'DELETE' ? 'OLD' : 'NEW', + current = tuple(table, prefix) + const regenerate = + event === 'INSERT' ? '1' : event === 'UPDATE' ? `NOT (${exactWhere(table, 'OLD', 'NEW')})` : '0' + const writePhysical = (p: string, fresh: string) => + `INSERT INTO snapshot_journal_physical(${physicalKey},revision,generation,present) SELECT ${tableId},${tuple(table, p).join(',')},${revision},${revision},EXISTS(SELECT 1 FROM ${q(table)} s WHERE ${exactWhere(table, p, 's')}) WHERE ${writable} ON CONFLICT(${physicalKey}) DO UPDATE SET revision=excluded.revision,generation=CASE WHEN ${fresh} THEN excluded.generation ELSE snapshot_journal_physical.generation END,present=excluded.present; ` + let body = keyGuard(current, tableId < 8 ? prefix + '.userId' : undefined) + if (event === 'UPDATE') body += keyGuard(tuple(table, 'OLD'), tableId < 8 ? 'OLD.userId' : undefined) + body += tick + if (event === 'UPDATE') body += writePhysical('OLD', '0') + body += writePhysical(prefix, regenerate) + if (tableId < 8) { + body += scopeUpsert( + `SELECT ${tableId},s.userId,s.${q(numeric[tableId].key)},0,'',${revision},1 FROM ${q(table)} s WHERE ${exactWhere(table, prefix, 's')} AND ${writable}` + ) + } else if (tableId === 10 || tableId === 11) { + body += scopeUpsert( + `SELECT ${tableId},snapshotUserId,snapshotLeftId,snapshotRightId,'',${revision},1 FROM ${names.relation} WHERE snapshotTableId=${tableId - 10} AND snapshotLeftId=${current[0]} AND snapshotRightId=${current[1]} AND ${writable}` + ) + } else if (tableId === 12) { + body += scopeUpsert( + `SELECT 12,snapshotUserId,snapshotCertificateId,0,snapshotFieldName,${revision},1 FROM ${names.certificate} WHERE snapshotCertificateId=${current[0]} AND snapshotFieldName=${current[2]} AND ${writable}` + ) + } + definitions.push( + `CREATE TRIGGER snapshot_journal_physical_${tableId}_${event} AFTER ${event} ON ${q(table)} ${event === 'UPDATE' ? 'WHEN ' + changed : ''} BEGIN ${body} END` + ) + } + }) + return definitions +} diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json new file mode 100644 index 000000000..e0d3771c2 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json @@ -0,0 +1,244 @@ +{ + "epoch": "8f342f6f-9112-4323-bdad-dafdf3e0f9e0", + "ddl": [ + { + "sql": "CREATE TABLE snapshot_journal_generation(id INTEGER NOT NULL PRIMARY KEY,version INTEGER NOT NULL,epoch VARCHAR(36) NOT NULL,source VARCHAR(64) NOT NULL,plan VARCHAR(64) NOT NULL,ceiling VARCHAR(19) NOT NULL,nextObject INTEGER NOT NULL,complete BOOLEAN NOT NULL) ENGINE=InnoDB DEFAULT CHARACTER SET ascii COLLATE ascii_bin ROW_FORMAT=DYNAMIC SELECT 1 id,1 version,? epoch,? source,? plan,? ceiling,0 nextObject,0 complete", + "bindings": [ + "8f342f6f-9112-4323-bdad-dafdf3e0f9e0", + "166b30f8f3e2c27b0bdc36a22b1aa86514cd245a3ad0ce84f69c215890a3b2ee", + "f3c86bd5f1bd18744033dbe85036e53f667caa741613b58cf985ba8e7628881c", + "9223372036854775807" + ] + }, + { + "sql": "CREATE TABLE snapshot_journal_clock(id INTEGER NOT NULL PRIMARY KEY,ceiling BIGINT UNSIGNED NOT NULL,CHECK(id=1)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0' SELECT 1 id,? ceiling", + "bindings": [ + "9223372036854775807" + ] + }, + { + "sql": "CREATE TABLE snapshot_journal_events(revision BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0'", + "bindings": [] + }, + { + "sql": "CREATE TABLE snapshot_journal_invalid(id INTEGER NOT NULL PRIMARY KEY,reason VARCHAR(32) NOT NULL,CHECK(id=1),CHECK(reason IN ('capacity-exhausted','revision-exhausted','key-out-of-range'))) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0'", + "bindings": [] + }, + { + "sql": "CREATE TABLE snapshot_journal_physical(tableId INT NOT NULL,id1 BIGINT UNSIGNED NOT NULL,id2 BIGINT UNSIGNED NOT NULL,exactText VARBINARY(400) NOT NULL,revision BIGINT UNSIGNED NOT NULL,generation BIGINT UNSIGNED NOT NULL,present BOOLEAN NOT NULL,PRIMARY KEY(tableId,id1,id2,exactText),KEY snapshot_journal_physical_page(tableId,revision,id1,id2,exactText)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0'", + "bindings": [] + }, + { + "sql": "CREATE TABLE snapshot_journal_scope(tableId INT NOT NULL,userId BIGINT UNSIGNED NOT NULL,id1 BIGINT UNSIGNED NOT NULL,id2 BIGINT UNSIGNED NOT NULL,exactText VARBINARY(400) NOT NULL,revision BIGINT UNSIGNED NOT NULL,present BOOLEAN NOT NULL,PRIMARY KEY(tableId,userId,id1,id2,exactText),KEY snapshot_journal_scope_page(userId,tableId,revision,id1,id2,exactText)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0'", + "bindings": [] + }, + { + "sql": "CREATE TABLE snapshot_journal_bootstrap(id INTEGER NOT NULL PRIMARY KEY,stream INTEGER NOT NULL,`cursor` TEXT,CHECK(id=1),CHECK(stream BETWEEN 0 AND 17)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0' SELECT 1 id,0 stream,NULL `cursor`", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_0_INSERT AFTER INSERT ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_0_DELETE AFTER DELETE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_0_UPDATE AFTER UPDATE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_1_INSERT AFTER INSERT ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_1_DELETE AFTER DELETE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_1_UPDATE AFTER UPDATE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_2_INSERT AFTER INSERT ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_2_DELETE AFTER DELETE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_2_UPDATE AFTER UPDATE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_3_INSERT AFTER INSERT ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_3_DELETE AFTER DELETE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_3_UPDATE AFTER UPDATE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_0_INSERT AFTER INSERT ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_0_UPDATE AFTER UPDATE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_0_DELETE AFTER DELETE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_1_INSERT AFTER INSERT ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_1_UPDATE AFTER UPDATE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_1_DELETE AFTER DELETE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_2_INSERT AFTER INSERT ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_2_UPDATE AFTER UPDATE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_2_DELETE AFTER DELETE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_3_INSERT AFTER INSERT ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_3_UPDATE AFTER UPDATE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_3_DELETE AFTER DELETE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_4_INSERT AFTER INSERT ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_4_UPDATE AFTER UPDATE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_4_DELETE AFTER DELETE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_5_INSERT AFTER INSERT ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_5_UPDATE AFTER UPDATE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_5_DELETE AFTER DELETE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_6_INSERT AFTER INSERT ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_6_UPDATE AFTER UPDATE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_6_DELETE AFTER DELETE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_7_INSERT AFTER INSERT ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_7_UPDATE AFTER UPDATE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_7_DELETE AFTER DELETE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_8_INSERT AFTER INSERT ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_8_UPDATE AFTER UPDATE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_8_DELETE AFTER DELETE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_9_INSERT AFTER INSERT ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_9_UPDATE AFTER UPDATE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_9_DELETE AFTER DELETE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_10_INSERT AFTER INSERT ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_10_UPDATE AFTER UPDATE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_10_DELETE AFTER DELETE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_11_INSERT AFTER INSERT ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_11_UPDATE AFTER UPDATE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_11_DELETE AFTER DELETE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_12_INSERT AFTER INSERT ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_12_UPDATE AFTER UPDATE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_physical_12_DELETE AFTER DELETE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "bindings": [] + } + ] +} \ No newline at end of file diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json new file mode 100644 index 000000000..33c675796 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json @@ -0,0 +1,13389 @@ +[ + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "knex_migrations" + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_profile_keys" + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_profile_index_progress" + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_profile_keys" + ], + "rows": [ + { + "name": "snapshotTableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "snapshotUserId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "snapshotRowId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", + "bindings": [ + "snapshot_profile_keys" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "snapshotRowId", + "nonUnique": 0, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_profile_index_progress" + ], + "rows": [ + { + "name": "snapshotTableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "afterRowId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "complete", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", + "bindings": [ + "snapshot_profile_index_progress" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_relation_keys" + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_relation_index_progress" + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_relation_keys" + ], + "rows": [ + { + "name": "snapshotTableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "snapshotUserId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "snapshotLeftId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "snapshotRightId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "snapshotMembership", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", + "bindings": [ + "snapshot_relation_keys" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "snapshotLeftId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "snapshotRightId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_relation_map", + "columnName": "snapshotTableId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_relation_map", + "columnName": "snapshotLeftId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_relation_map", + "columnName": "snapshotRightId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_relation_map", + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_relation_right", + "columnName": "snapshotTableId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_relation_right", + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_relation_right", + "columnName": "snapshotRightId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_relation_right", + "columnName": "snapshotLeftId", + "nonUnique": 1, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_relation_index_progress" + ], + "rows": [ + { + "name": "snapshotTableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "afterLeftId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "afterRightId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "complete", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", + "bindings": [ + "snapshot_relation_index_progress" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_certificate_field_keys" + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_certificate_index_progress" + ] + }, + { + "sql": "SELECT TABLE_NAME AS name, ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?, ?) ORDER BY TABLE_NAME", + "bindings": [ + "certificate_fields", + "certificates" + ], + "rows": [ + { + "name": "certificate_fields", + "engine": "InnoDB" + }, + { + "name": "certificates", + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT COLUMN_TYPE AS type, IS_NULLABLE AS nullable, CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + "bindings": [ + "certificate_fields", + "fieldName" + ], + "rows": [ + { + "type": "varchar(100)", + "nullable": "NO", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + } + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", + "bindings": [ + "snapshot_certificate_field_keys" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra, CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_certificate_field_keys" + ], + "rows": [ + { + "name": "snapshotUserId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "snapshotFieldName", + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "snapshotCertificateId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "snapshotMembership", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", + "bindings": [ + "snapshot_certificate_field_keys" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "snapshotFieldName", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "snapshotCertificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_certificate_lookup", + "columnName": "snapshotFieldName", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_certificate_lookup", + "columnName": "snapshotCertificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_certificate_lookup", + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_certificate_parent", + "columnName": "snapshotCertificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_certificate_parent", + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_certificate_parent", + "columnName": "snapshotFieldName", + "nonUnique": 1, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", + "bindings": [ + "snapshot_certificate_index_progress" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra, CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_certificate_index_progress" + ], + "rows": [ + { + "name": "snapshotTableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "started", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "afterFieldName", + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "afterCertificateId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "complete", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", + "bindings": [ + "snapshot_certificate_index_progress" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "proven_txs" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "proven_txs" + ], + "rows": [] + }, + { + "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "proven_txs" + ], + "rows": [ + { + "name": "blockHash", + "type": "varchar(64)", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null + }, + { + "name": "height", + "type": "int unsigned", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "index", + "type": "int unsigned", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "merklePath", + "type": "blob", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "merkleRoot", + "type": "varchar(64)", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "provenTxId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment", + "charset": null, + "collation": null + }, + { + "name": "rawTx", + "type": "longblob", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "txid", + "type": "varchar(64)", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "proven_txs" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "provenTxId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "proven_txs_blockhash_index", + "columnName": "blockHash", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "proven_txs_txid_unique", + "columnName": "txid", + "nonUnique": 0, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "proven_tx_reqs" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "proven_tx_reqs" + ], + "rows": [ + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "proven_tx_reqs" + ], + "rows": [ + { + "name": "attempts", + "type": "int unsigned", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "batch", + "type": "varchar(64)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null + }, + { + "name": "history", + "type": "longtext", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "inputBEEF", + "type": "longblob", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "notified", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "notify", + "type": "longtext", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "provenTxId", + "type": "int unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "provenTxReqId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment", + "charset": null, + "collation": null + }, + { + "name": "rawTx", + "type": "longblob", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "rebroadcastAttempts", + "type": "int unsigned", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "status", + "type": "varchar(16)", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "txid", + "type": "varchar(64)", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null + }, + { + "name": "wasBroadcast", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "proven_tx_reqs" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "provenTxReqId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "proven_tx_reqs_batch_index", + "columnName": "batch", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "proven_tx_reqs_proventxid_foreign", + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "proven_tx_reqs_status_index", + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "proven_tx_reqs_txid_index", + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "proven_tx_reqs_txid_unique", + "columnName": "txid", + "nonUnique": 0, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "transactions" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "transactions" + ], + "rows": [ + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "transactions" + ], + "rows": [ + { + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null + }, + { + "name": "description", + "type": "varchar(2048)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "inputBEEF", + "type": "longblob", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "isOutgoing", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "lockTime", + "type": "int unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "noSendExpiryAnchorTxid", + "type": "varchar(64)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "noSendExpiryAnchorVout", + "type": "int unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "noSendExpiryDeadline", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "noSendExpiryMode", + "type": "varchar(16)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "noSendExpiryObservedAt", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "noSendExpiryReclaimDerivationPrefix", + "type": "varchar(32)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "noSendExpiryReclaimDerivationSuffix", + "type": "varchar(32)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "noSendExpiryReclaimRawTx", + "type": "longblob", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "noSendExpiryReclaimSatoshis", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "noSendExpiryReclaimTxid", + "type": "varchar(64)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "noSendExpiryReleasedAt", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "noSendExpiryState", + "type": "varchar(24)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "noSendExpiryValue", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "provenTxId", + "type": "int unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "rawTx", + "type": "longblob", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "reference", + "type": "varchar(64)", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "satoshis", + "type": "bigint", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "status", + "type": "varchar(64)", + "nullable": "NO", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "transactionId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment", + "charset": null, + "collation": null + }, + { + "name": "txid", + "type": "varchar(64)", + "nullable": "YES", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }, + { + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null + }, + { + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "", + "charset": null, + "collation": null + }, + { + "name": "version", + "type": "int unsigned", + "nullable": "YES", + "extra": "", + "charset": null, + "collation": null + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "transactions" + ], + "rows": [ + { + "name": "idx_transactions_nosend_expiry", + "columnName": "noSendExpiryState", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "idx_transactions_nosend_expiry", + "columnName": "noSendExpiryDeadline", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "idx_transactions_nosend_reclaim", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "idx_transactions_nosend_reclaim", + "columnName": "noSendExpiryReclaimTxid", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "idx_transactions_user_proven_tx", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "idx_transactions_user_proven_tx", + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "idx_transactions_user_txid", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "idx_transactions_user_txid", + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "transactions_proventxid_foreign", + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "transactions_reference_unique", + "columnName": "reference", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "transactions_status_index", + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "transactions_txid_index", + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_global_guards" + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_global_guards" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_global_guards" + ], + "rows": [ + { + "name": "proofId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "present", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "snapshot_global_guards" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "proofId", + "nonUnique": 0, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_global_keys" + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_global_keys" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_global_keys" + ], + "rows": [ + { + "name": "tableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "rowId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "refs", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "present", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "snapshot_global_keys" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "tableId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "rowId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_page", + "columnName": "tableId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_page", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_page", + "columnName": "present", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_page", + "columnName": "rowId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_target", + "columnName": "tableId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_target", + "columnName": "rowId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_target", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_global_edges" + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_global_edges" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_global_edges" + ], + "rows": [ + { + "name": "transactionId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "requestId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "tableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "rowId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "snapshot_global_edges" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "requestId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "tableId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "PRIMARY", + "columnName": "rowId", + "nonUnique": 0, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_request", + "columnName": "requestId", + "nonUnique": 1, + "direction": "A", + "prefix": null + }, + { + "name": "snapshot_global_request", + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", + "bindings": [ + "snapshot_global_index_progress" + ] + }, + { + "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_global_index_progress" + ], + "rows": [ + { + "engine": "InnoDB" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "snapshot_global_index_progress" + ], + "rows": [ + { + "name": "id", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "afterRowId", + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "" + }, + { + "name": "complete", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "snapshot_global_index_progress" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "id", + "nonUnique": 0, + "direction": "A", + "prefix": null + } + ] + }, + { + "sql": "SELECT TABLE_NAME name,ENGINE engine,TABLE_TYPE type FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?)", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields" + ], + "rows": [ + { + "name": "certificate_fields", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "certificates", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "commissions", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "output_baskets", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "output_tags", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "output_tags_map", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "outputs", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "proven_tx_reqs", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "proven_txs", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "sync_states", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "transactions", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "tx_labels", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "tx_labels_map", + "engine": "InnoDB", + "type": "BASE TABLE" + } + ] + }, + { + "sql": "SELECT TABLE_NAME tableName,COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,EXTRA extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?) ORDER BY TABLE_NAME,ORDINAL_POSITION LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields" + ], + "rows": [ + { + "tableName": "certificate_fields", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "certificate_fields", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "certificate_fields", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificate_fields", + "name": "certificateId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificate_fields", + "name": "fieldName", + "type": "varchar(100)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificate_fields", + "name": "fieldValue", + "type": "varchar(255)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificate_fields", + "name": "masterKey", + "type": "varchar(255)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificates", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "certificates", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "certificates", + "name": "certificateId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "certificates", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificates", + "name": "serialNumber", + "type": "varchar(100)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificates", + "name": "type", + "type": "varchar(100)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificates", + "name": "certifier", + "type": "varchar(100)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificates", + "name": "subject", + "type": "varchar(100)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificates", + "name": "verifier", + "type": "varchar(100)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "certificates", + "name": "revocationOutpoint", + "type": "varchar(100)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificates", + "name": "signature", + "type": "varchar(255)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "certificates", + "name": "isDeleted", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "commissions", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "commissions", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "commissions", + "name": "commissionId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "commissions", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "commissions", + "name": "transactionId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "commissions", + "name": "satoshis", + "type": "int", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "commissions", + "name": "keyOffset", + "type": "varchar(130)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "commissions", + "name": "isRedeemed", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "commissions", + "name": "lockingScript", + "type": "blob", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_baskets", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "output_baskets", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "output_baskets", + "name": "basketId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "output_baskets", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_baskets", + "name": "name", + "type": "varchar(300)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_baskets", + "name": "numberOfDesiredUTXOs", + "type": "int", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_baskets", + "name": "minimumDesiredUTXOValue", + "type": "int", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_baskets", + "name": "isDeleted", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_tags", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "output_tags", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "output_tags", + "name": "outputTagId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "output_tags", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_tags", + "name": "tag", + "type": "varchar(150)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_tags", + "name": "isDeleted", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_tags_map", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "output_tags_map", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "output_tags_map", + "name": "outputTagId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_tags_map", + "name": "outputId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "output_tags_map", + "name": "isDeleted", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "outputs", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "outputs", + "name": "outputId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "outputs", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "transactionId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "basketId", + "type": "int unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "spendable", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "change", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "vout", + "type": "int", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "satoshis", + "type": "bigint", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "providedBy", + "type": "varchar(130)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "purpose", + "type": "varchar(20)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "type", + "type": "varchar(50)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "outputs", + "name": "outputDescription", + "type": "varchar(2048)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "txid", + "type": "varchar(64)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "senderIdentityKey", + "type": "varchar(130)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "derivationPrefix", + "type": "varchar(200)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "derivationSuffix", + "type": "varchar(200)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "customInstructions", + "type": "varchar(2500)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "spentBy", + "type": "int unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "sequenceNumber", + "type": "int unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "spendingDescription", + "type": "varchar(2048)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "scriptLength", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "scriptOffset", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "outputs", + "name": "lockingScript", + "type": "longblob", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "proven_tx_reqs", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "proven_tx_reqs", + "name": "provenTxReqId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "proven_tx_reqs", + "name": "provenTxId", + "type": "int unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "status", + "type": "varchar(16)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "attempts", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "notified", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "txid", + "type": "varchar(64)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "batch", + "type": "varchar(64)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "history", + "type": "longtext", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "notify", + "type": "longtext", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "rawTx", + "type": "longblob", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "inputBEEF", + "type": "longblob", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "wasBroadcast", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "rebroadcastAttempts", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_txs", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "proven_txs", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "proven_txs", + "name": "provenTxId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "proven_txs", + "name": "txid", + "type": "varchar(64)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_txs", + "name": "height", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_txs", + "name": "index", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_txs", + "name": "merklePath", + "type": "blob", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_txs", + "name": "rawTx", + "type": "longblob", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "proven_txs", + "name": "blockHash", + "type": "varchar(64)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "proven_txs", + "name": "merkleRoot", + "type": "varchar(64)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "sync_states", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "sync_states", + "name": "syncStateId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "sync_states", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "storageIdentityKey", + "type": "varchar(130)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "storageName", + "type": "varchar(255)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "status", + "type": "varchar(255)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "init", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "refNum", + "type": "varchar(100)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "syncMap", + "type": "longtext", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "when", + "type": "datetime", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "satoshis", + "type": "bigint", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "errorLocal", + "type": "longtext", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "sync_states", + "name": "errorOther", + "type": "longtext", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "transactions", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "transactions", + "name": "transactionId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "transactions", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "transactions", + "name": "provenTxId", + "type": "int unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "status", + "type": "varchar(64)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "transactions", + "name": "reference", + "type": "varchar(64)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "transactions", + "name": "isOutgoing", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "transactions", + "name": "satoshis", + "type": "bigint", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "transactions", + "name": "version", + "type": "int unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "lockTime", + "type": "int unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "description", + "type": "varchar(2048)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "txid", + "type": "varchar(64)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "inputBEEF", + "type": "longblob", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "rawTx", + "type": "longblob", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryMode", + "type": "varchar(16)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryValue", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryDeadline", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryState", + "type": "varchar(24)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryAnchorTxid", + "type": "varchar(64)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryAnchorVout", + "type": "int unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReleasedAt", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryObservedAt", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimTxid", + "type": "varchar(64)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimRawTx", + "type": "longblob", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimDerivationPrefix", + "type": "varchar(32)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimDerivationSuffix", + "type": "varchar(32)", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimSatoshis", + "type": "bigint unsigned", + "nullable": "YES", + "extra": "" + }, + { + "tableName": "tx_labels", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "tx_labels", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "tx_labels", + "name": "txLabelId", + "type": "int unsigned", + "nullable": "NO", + "extra": "auto_increment" + }, + { + "tableName": "tx_labels", + "name": "userId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "tx_labels", + "name": "label", + "type": "varchar(300)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "tx_labels", + "name": "isDeleted", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "tx_labels_map", + "name": "created_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "tx_labels_map", + "name": "updated_at", + "type": "timestamp(3)", + "nullable": "NO", + "extra": "DEFAULT_GENERATED" + }, + { + "tableName": "tx_labels_map", + "name": "txLabelId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "tx_labels_map", + "name": "transactionId", + "type": "int unsigned", + "nullable": "NO", + "extra": "" + }, + { + "tableName": "tx_labels_map", + "name": "isDeleted", + "type": "tinyint(1)", + "nullable": "NO", + "extra": "" + } + ] + }, + { + "sql": "SELECT TABLE_NAME tableName,INDEX_NAME name,COLUMN_NAME columnName,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,IS_VISIBLE visible FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?) ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields" + ], + "rows": [ + { + "tableName": "certificate_fields", + "name": "certificate_fields_certificateid_foreign", + "columnName": "certificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_fieldname_certificateid_unique", + "columnName": "fieldName", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_fieldname_certificateid_unique", + "columnName": "certificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_userid_foreign", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "columnName": "type", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "columnName": "certifier", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "columnName": "serialNumber", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "certificates", + "name": "PRIMARY", + "columnName": "certificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_index", + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_unique", + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "commissions", + "name": "commissions_userid_foreign", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "commissions", + "name": "PRIMARY", + "columnName": "commissionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_baskets", + "name": "output_baskets_name_userid_unique", + "columnName": "name", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_baskets", + "name": "output_baskets_name_userid_unique", + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_baskets", + "name": "output_baskets_userid_foreign", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_baskets", + "name": "PRIMARY", + "columnName": "basketId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags", + "name": "output_tags_tag_userid_unique", + "columnName": "tag", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags", + "name": "output_tags_tag_userid_unique", + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags", + "name": "output_tags_userid_foreign", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags", + "name": "PRIMARY", + "columnName": "outputTagId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "columnName": "isDeleted", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "columnName": "outputTagId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputid_index", + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_outputid_unique", + "columnName": "outputTagId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_outputid_unique", + "columnName": "outputId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "columnName": "spentBy", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "columnName": "satoshis", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_spentby", + "columnName": "spentBy", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "columnName": "satoshis", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "outputs_basketid_foreign", + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "outputs_spendable_index", + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "columnName": "vout", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "outputs", + "name": "PRIMARY", + "columnName": "outputId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_tx_reqs", + "name": "PRIMARY", + "columnName": "provenTxReqId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_batch_index", + "columnName": "batch", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_proventxid_foreign", + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_status_index", + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_txid_index", + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_txid_unique", + "columnName": "txid", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_txs", + "name": "PRIMARY", + "columnName": "provenTxId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_txs", + "name": "proven_txs_blockhash_index", + "columnName": "blockHash", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "proven_txs", + "name": "proven_txs_txid_unique", + "columnName": "txid", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "sync_states", + "name": "PRIMARY", + "columnName": "syncStateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "sync_states", + "name": "sync_states_refnum_index", + "columnName": "refNum", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "sync_states", + "name": "sync_states_refnum_unique", + "columnName": "refNum", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "sync_states", + "name": "sync_states_status_index", + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "sync_states", + "name": "sync_states_userid_foreign", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_expiry", + "columnName": "noSendExpiryState", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_expiry", + "columnName": "noSendExpiryDeadline", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_reclaim", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_reclaim", + "columnName": "noSendExpiryReclaimTxid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_proven_tx", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_proven_tx", + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_txid", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_txid", + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "PRIMARY", + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "transactions_proventxid_foreign", + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "transactions_reference_unique", + "columnName": "reference", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "transactions_status_index", + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "transactions", + "name": "transactions_txid_index", + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels", + "name": "PRIMARY", + "columnName": "txLabelId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels", + "name": "tx_labels_label_userid_unique", + "columnName": "label", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels", + "name": "tx_labels_label_userid_unique", + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels", + "name": "tx_labels_userid_foreign", + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels_map", + "name": "idx_tx_labels_map_tx_deleted", + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels_map", + "name": "idx_tx_labels_map_tx_deleted", + "columnName": "isDeleted", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_transactionid_index", + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_transactionid_unique", + "columnName": "txLabelId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_transactionid_unique", + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "visible": "YES" + } + ] + }, + { + "sql": "SELECT UPDATE_RULE updateRule,DELETE_RULE deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?)", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields" + ], + "rows": [ + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "bindings": [ + 1873, + "snapshot_profile_0_insert", + "snapshot_profile_0_update", + "snapshot_profile_0_delete", + "snapshot_profile_1_insert", + "snapshot_profile_1_update", + "snapshot_profile_1_delete", + "snapshot_profile_2_insert", + "snapshot_profile_2_update", + "snapshot_profile_2_delete", + "snapshot_profile_3_insert", + "snapshot_profile_3_update", + "snapshot_profile_3_delete", + "snapshot_profile_4_insert", + "snapshot_profile_4_update", + "snapshot_profile_4_delete", + "snapshot_profile_5_insert", + "snapshot_profile_5_update", + "snapshot_profile_5_delete", + "snapshot_profile_6_insert", + "snapshot_profile_6_update", + "snapshot_profile_6_delete", + "snapshot_profile_7_insert", + "snapshot_profile_7_update", + "snapshot_profile_7_delete", + "snapshot_relation_0_map_delete", + "snapshot_relation_0_map_before_update", + "snapshot_relation_0_left_delete", + "snapshot_relation_0_left_before_update", + "snapshot_relation_0_right_delete", + "snapshot_relation_0_right_before_update", + "snapshot_relation_1_map_delete", + "snapshot_relation_1_map_before_update", + "snapshot_relation_1_left_delete", + "snapshot_relation_1_left_before_update", + "snapshot_relation_1_right_delete", + "snapshot_relation_1_right_before_update", + "snapshot_relation_0_map_insert", + "snapshot_relation_0_map_after_update", + "snapshot_relation_0_left_insert", + "snapshot_relation_0_left_after_update", + "snapshot_relation_0_right_insert", + "snapshot_relation_0_right_after_update", + "snapshot_relation_1_map_insert", + "snapshot_relation_1_map_after_update", + "snapshot_relation_1_left_insert", + "snapshot_relation_1_left_after_update", + "snapshot_relation_1_right_insert", + "snapshot_relation_1_right_after_update", + "snapshot_certificate_field_delete", + "snapshot_certificate_field_before_update", + "snapshot_certificate_parent_delete", + "snapshot_certificate_parent_before_update", + "snapshot_certificate_field_insert", + "snapshot_certificate_field_after_update", + "snapshot_certificate_parent_insert", + "snapshot_certificate_parent_after_update", + "snapshot_global_edge_delete", + "snapshot_global_edge_insert", + "snapshot_global_tx_delete", + "snapshot_global_tx_before_update", + "snapshot_global_req_delete", + "snapshot_global_req_before_update", + "snapshot_global_proof_delete", + "snapshot_global_proof_before_update", + "snapshot_global_proof_insert", + "snapshot_global_proof_after_update", + "snapshot_global_tx_insert", + "snapshot_global_tx_after_update", + "snapshot_global_req_insert", + "snapshot_global_req_after_update" + ], + "rows": [ + { + "name": "snapshot_profile_0_delete", + "table": "transactions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; END" + }, + { + "name": "snapshot_profile_0_update", + "table": "transactions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END IF; END" + }, + { + "name": "snapshot_profile_0_insert", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END" + }, + { + "name": "snapshot_profile_1_delete", + "table": "outputs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; END" + }, + { + "name": "snapshot_profile_1_update", + "table": "outputs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END IF; END" + }, + { + "name": "snapshot_profile_1_insert", + "table": "outputs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END" + }, + { + "name": "snapshot_profile_2_delete", + "table": "certificates", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; END" + }, + { + "name": "snapshot_profile_2_update", + "table": "certificates", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END IF; END" + }, + { + "name": "snapshot_profile_2_insert", + "table": "certificates", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END" + }, + { + "name": "snapshot_profile_4_delete", + "table": "output_baskets", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; END" + }, + { + "name": "snapshot_profile_4_update", + "table": "output_baskets", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.basketId <=> NEW.basketId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END IF; END" + }, + { + "name": "snapshot_profile_4_insert", + "table": "output_baskets", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END" + }, + { + "name": "snapshot_profile_5_delete", + "table": "output_tags", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; END" + }, + { + "name": "snapshot_profile_5_update", + "table": "output_tags", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputTagId <=> NEW.outputTagId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END IF; END" + }, + { + "name": "snapshot_profile_5_insert", + "table": "output_tags", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END" + }, + { + "name": "snapshot_profile_6_delete", + "table": "commissions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; END" + }, + { + "name": "snapshot_profile_6_update", + "table": "commissions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.commissionId <=> NEW.commissionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END IF; END" + }, + { + "name": "snapshot_profile_6_insert", + "table": "commissions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END" + }, + { + "name": "snapshot_profile_7_delete", + "table": "sync_states", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; END" + }, + { + "name": "snapshot_profile_7_update", + "table": "sync_states", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.syncStateId <=> NEW.syncStateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END IF; END" + }, + { + "name": "snapshot_profile_7_insert", + "table": "sync_states", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END" + }, + { + "name": "snapshot_relation_0_map_delete", + "table": "tx_labels_map", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END" + }, + { + "name": "snapshot_relation_0_map_before_update", + "table": "tx_labels_map", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END IF; END" + }, + { + "name": "snapshot_relation_0_right_delete", + "table": "transactions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_relation_0_right_before_update", + "table": "transactions", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_relation_1_map_delete", + "table": "output_tags_map", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END" + }, + { + "name": "snapshot_relation_1_map_before_update", + "table": "output_tags_map", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END IF; END" + }, + { + "name": "snapshot_relation_1_left_delete", + "table": "output_tags", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_relation_1_left_before_update", + "table": "output_tags", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_relation_1_right_delete", + "table": "outputs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_relation_1_right_before_update", + "table": "outputs", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_relation_0_map_insert", + "table": "tx_labels_map", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + }, + { + "name": "snapshot_relation_0_map_after_update", + "table": "tx_labels_map", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_relation_0_right_insert", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + }, + { + "name": "snapshot_relation_0_right_after_update", + "table": "transactions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_relation_1_map_insert", + "table": "output_tags_map", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + }, + { + "name": "snapshot_relation_1_map_after_update", + "table": "output_tags_map", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_relation_1_left_insert", + "table": "output_tags", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" + }, + { + "name": "snapshot_relation_1_left_after_update", + "table": "output_tags", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" + }, + { + "name": "snapshot_relation_1_right_insert", + "table": "outputs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + }, + { + "name": "snapshot_relation_1_right_after_update", + "table": "outputs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_certificate_field_delete", + "table": "certificate_fields", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END" + }, + { + "name": "snapshot_certificate_field_before_update", + "table": "certificate_fields", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END IF; END" + }, + { + "name": "snapshot_certificate_parent_delete", + "table": "certificates", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_certificate_parent_before_update", + "table": "certificates", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_certificate_field_insert", + "table": "certificate_fields", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END" + }, + { + "name": "snapshot_certificate_field_after_update", + "table": "certificate_fields", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_certificate_parent_insert", + "table": "certificates", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END" + }, + { + "name": "snapshot_certificate_parent_after_update", + "table": "certificates", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_global_edge_delete", + "table": "snapshot_global_edges", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_global_keys SET refs=refs-1 WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId; DELETE FROM snapshot_global_keys WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId AND refs=0; DELETE FROM snapshot_global_guards WHERE proofId=OLD.rowId AND OLD.tableId=1 AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.rowId); END" + }, + { + "name": "snapshot_global_edge_insert", + "table": "snapshot_global_edges", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_global_keys (tableId,userId,rowId,refs,present) VALUES (NEW.tableId,NEW.userId,NEW.rowId,1,CASE WHEN NEW.tableId=0 THEN 1 ELSE (SELECT present FROM snapshot_global_guards WHERE proofId=NEW.rowId FOR SHARE) END) ON DUPLICATE KEY UPDATE refs=snapshot_global_keys.refs+1; END" + }, + { + "name": "snapshot_global_tx_delete", + "table": "transactions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END" + }, + { + "name": "snapshot_global_tx_before_update", + "table": "transactions", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END IF; END" + }, + { + "name": "snapshot_global_req_delete", + "table": "proven_tx_reqs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END" + }, + { + "name": "snapshot_global_req_before_update", + "table": "proven_tx_reqs", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END IF; END" + }, + { + "name": "snapshot_global_proof_delete", + "table": "proven_txs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END" + }, + { + "name": "snapshot_global_proof_before_update", + "table": "proven_txs", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END IF; END" + }, + { + "name": "snapshot_global_proof_insert", + "table": "proven_txs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END" + }, + { + "name": "snapshot_global_proof_after_update", + "table": "proven_txs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" + }, + { + "name": "snapshot_global_tx_insert", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END" + }, + { + "name": "snapshot_global_tx_after_update", + "table": "transactions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END IF; END" + }, + { + "name": "snapshot_global_req_insert", + "table": "proven_tx_reqs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END" + }, + { + "name": "snapshot_global_req_after_update", + "table": "proven_tx_reqs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" + }, + { + "name": "snapshot_relation_0_left_before_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_profile_3_insert", + "table": "tx_labels", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END" + }, + { + "name": "snapshot_relation_0_left_insert", + "table": "tx_labels", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" + }, + { + "name": "snapshot_profile_3_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txLabelId <=> NEW.txLabelId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END IF; END" + }, + { + "name": "snapshot_relation_0_left_after_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" + }, + { + "name": "snapshot_profile_3_delete", + "table": "tx_labels", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; END" + }, + { + "name": "snapshot_relation_0_left_delete", + "table": "tx_labels", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END" + } + ] + }, + { + "sql": "SELECT TABLE_NAME name,ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,SUBSTRING(CREATE_OPTIONS,1,16385) options FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ORDER BY TABLE_NAME LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields", + "users", + "settings", + "snapshot_profile_keys", + "snapshot_relation_keys", + "snapshot_certificate_field_keys", + "snapshot_global_edges", + "snapshot_global_keys", + "snapshot_global_guards" + ], + "rows": [ + { + "name": "certificate_fields", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "certificates", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "commissions", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "output_baskets", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "output_tags", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "output_tags_map", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "outputs", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "proven_tx_reqs", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "proven_txs", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "settings", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_certificate_field_keys", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_global_edges", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_global_guards", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_global_keys", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_profile_keys", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_relation_keys", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "sync_states", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "transactions", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "tx_labels", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "tx_labels_map", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "users", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + } + ] + }, + { + "sql": "SELECT TABLE_NAME tableName,COLUMN_NAME name,ORDINAL_POSITION position,COLUMN_TYPE type,IS_NULLABLE nullable,SUBSTRING(COLUMN_DEFAULT,1,16385) defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,SUBSTRING(GENERATION_EXPRESSION,1,16385) expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ORDER BY TABLE_NAME,ORDINAL_POSITION LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields", + "users", + "settings", + "snapshot_profile_keys", + "snapshot_relation_keys", + "snapshot_certificate_field_keys", + "snapshot_global_edges", + "snapshot_global_keys", + "snapshot_global_guards" + ], + "rows": [ + { + "tableName": "certificate_fields", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "userId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "certificateId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "fieldName", + "position": 5, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "fieldValue", + "position": 6, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "masterKey", + "position": 7, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": "", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificates", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificates", + "name": "certificateId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificates", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificates", + "name": "serialNumber", + "position": 5, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "type", + "position": 6, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "certifier", + "position": 7, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "subject", + "position": 8, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "verifier", + "position": 9, + "type": "varchar(100)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "revocationOutpoint", + "position": 10, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "signature", + "position": 11, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "isDeleted", + "position": 12, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "commissionId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "transactionId", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "satoshis", + "position": 6, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "keyOffset", + "position": 7, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "commissions", + "name": "isRedeemed", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "lockingScript", + "position": 9, + "type": "blob", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "basketId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "name", + "position": 5, + "type": "varchar(300)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "numberOfDesiredUTXOs", + "position": 6, + "type": "int", + "nullable": "NO", + "defaultValue": "144", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "minimumDesiredUTXOValue", + "position": 7, + "type": "int", + "nullable": "NO", + "defaultValue": "5000", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "isDeleted", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "outputTagId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "tag", + "position": 5, + "type": "varchar(150)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "output_tags", + "name": "isDeleted", + "position": 6, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "outputTagId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "outputId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "isDeleted", + "position": 5, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "outputId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "transactionId", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "basketId", + "position": 6, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "spendable", + "position": 7, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "change", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "vout", + "position": 9, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "satoshis", + "position": 10, + "type": "bigint", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "providedBy", + "position": 11, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "purpose", + "position": 12, + "type": "varchar(20)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "type", + "position": 13, + "type": "varchar(50)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "outputDescription", + "position": 14, + "type": "varchar(2048)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "txid", + "position": 15, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "senderIdentityKey", + "position": 16, + "type": "varchar(130)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "derivationPrefix", + "position": 17, + "type": "varchar(200)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "derivationSuffix", + "position": 18, + "type": "varchar(200)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "customInstructions", + "position": 19, + "type": "varchar(2500)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "spentBy", + "position": 20, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "sequenceNumber", + "position": 21, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "spendingDescription", + "position": 22, + "type": "varchar(2048)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "scriptLength", + "position": 23, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "scriptOffset", + "position": 24, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "lockingScript", + "position": 25, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "provenTxReqId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "provenTxId", + "position": 4, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "status", + "position": 5, + "type": "varchar(16)", + "nullable": "NO", + "defaultValue": "unknown", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "attempts", + "position": 6, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "notified", + "position": 7, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "txid", + "position": 8, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "batch", + "position": 9, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "history", + "position": 10, + "type": "longtext", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "notify", + "position": 11, + "type": "longtext", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "rawTx", + "position": 12, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "inputBEEF", + "position": 13, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "wasBroadcast", + "position": 14, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "rebroadcastAttempts", + "position": 15, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "provenTxId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "txid", + "position": 4, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "height", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "index", + "position": 6, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "merklePath", + "position": 7, + "type": "blob", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "rawTx", + "position": 8, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "blockHash", + "position": 9, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "merkleRoot", + "position": 10, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "settings", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "settings", + "name": "storageIdentityKey", + "position": 3, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "storageName", + "position": 4, + "type": "varchar(128)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "chain", + "position": 5, + "type": "varchar(10)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "dbtype", + "position": 6, + "type": "varchar(10)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "maxOutputScript", + "position": 7, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshotUserId", + "position": 1, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshotFieldName", + "position": 2, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshotCertificateId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshotMembership", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "transactionId", + "position": 1, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "requestId", + "position": 2, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "tableId", + "position": 3, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "rowId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "userId", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_guards", + "name": "proofId", + "position": 1, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_guards", + "name": "present", + "position": 2, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "tableId", + "position": 1, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "userId", + "position": 2, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "rowId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "refs", + "position": 4, + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "present", + "position": 5, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_profile_keys", + "name": "snapshotTableId", + "position": 1, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_profile_keys", + "name": "snapshotUserId", + "position": 2, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_profile_keys", + "name": "snapshotRowId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotTableId", + "position": 1, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotUserId", + "position": 2, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotLeftId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotRightId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotMembership", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "syncStateId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "storageIdentityKey", + "position": 5, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": "", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "storageName", + "position": 6, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "status", + "position": 7, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": "unknown", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "init", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "refNum", + "position": 9, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "syncMap", + "position": 10, + "type": "longtext", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "when", + "position": 11, + "type": "datetime", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "satoshis", + "position": 12, + "type": "bigint", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "errorLocal", + "position": 13, + "type": "longtext", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "errorOther", + "position": 14, + "type": "longtext", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "transactionId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "provenTxId", + "position": 5, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "status", + "position": 6, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "reference", + "position": 7, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "isOutgoing", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "satoshis", + "position": 9, + "type": "bigint", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "version", + "position": 10, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "lockTime", + "position": 11, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "description", + "position": 12, + "type": "varchar(2048)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "txid", + "position": 13, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "inputBEEF", + "position": 14, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "rawTx", + "position": 15, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryMode", + "position": 16, + "type": "varchar(16)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryValue", + "position": 17, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryDeadline", + "position": 18, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryState", + "position": 19, + "type": "varchar(24)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryAnchorTxid", + "position": 20, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryAnchorVout", + "position": 21, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReleasedAt", + "position": 22, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryObservedAt", + "position": 23, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimTxid", + "position": 24, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimRawTx", + "position": 25, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimDerivationPrefix", + "position": 26, + "type": "varchar(32)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimDerivationSuffix", + "position": 27, + "type": "varchar(32)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimSatoshis", + "position": 28, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "txLabelId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "label", + "position": 5, + "type": "varchar(300)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "isDeleted", + "position": 6, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "txLabelId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "transactionId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "isDeleted", + "position": 5, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "users", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "users", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "users", + "name": "userId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "users", + "name": "identityKey", + "position": 4, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "users", + "name": "activeStorage", + "position": 5, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + } + ] + }, + { + "sql": "SELECT TABLE_NAME tableName,INDEX_NAME name,SEQ_IN_INDEX position,COLUMN_NAME columnName,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,NULLABLE nullable,INDEX_TYPE type,IS_VISIBLE visible,SUBSTRING(EXPRESSION,1,16385) expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields", + "users", + "settings", + "snapshot_profile_keys", + "snapshot_relation_keys", + "snapshot_certificate_field_keys", + "snapshot_global_edges", + "snapshot_global_keys", + "snapshot_global_guards" + ], + "rows": [ + { + "tableName": "certificate_fields", + "name": "certificate_fields_certificateid_foreign", + "position": 1, + "columnName": "certificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_fieldname_certificateid_unique", + "position": 1, + "columnName": "fieldName", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_fieldname_certificateid_unique", + "position": 2, + "columnName": "certificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "position": 1, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "position": 2, + "columnName": "type", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "position": 3, + "columnName": "certifier", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "position": 4, + "columnName": "serialNumber", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "PRIMARY", + "position": 1, + "columnName": "certificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_index", + "position": 1, + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_unique", + "position": 1, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "commissions", + "name": "commissions_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "commissions", + "name": "PRIMARY", + "position": 1, + "columnName": "commissionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_baskets", + "name": "output_baskets_name_userid_unique", + "position": 1, + "columnName": "name", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_baskets", + "name": "output_baskets_name_userid_unique", + "position": 2, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_baskets", + "name": "output_baskets_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_baskets", + "name": "PRIMARY", + "position": 1, + "columnName": "basketId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags", + "name": "output_tags_tag_userid_unique", + "position": 1, + "columnName": "tag", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags", + "name": "output_tags_tag_userid_unique", + "position": 2, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags", + "name": "output_tags_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags", + "name": "PRIMARY", + "position": 1, + "columnName": "outputTagId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "position": 1, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "position": 2, + "columnName": "isDeleted", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "position": 3, + "columnName": "outputTagId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputid_index", + "position": 1, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_outputid_unique", + "position": 1, + "columnName": "outputTagId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_outputid_unique", + "position": 2, + "columnName": "outputId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 2, + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 3, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 4, + "columnName": "spentBy", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 5, + "columnName": "satoshis", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 6, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_spentby", + "position": 1, + "columnName": "spentBy", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "position": 2, + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "position": 3, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "position": 4, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "position": 2, + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "position": 3, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "position": 4, + "columnName": "satoshis", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "position": 2, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "position": 3, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_basketid_foreign", + "position": 1, + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_spendable_index", + "position": 1, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "position": 1, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "position": 2, + "columnName": "vout", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "position": 3, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "PRIMARY", + "position": 1, + "columnName": "outputId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "PRIMARY", + "position": 1, + "columnName": "provenTxReqId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_batch_index", + "position": 1, + "columnName": "batch", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_proventxid_foreign", + "position": 1, + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_status_index", + "position": 1, + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_txid_index", + "position": 1, + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_txid_unique", + "position": 1, + "columnName": "txid", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_txs", + "name": "PRIMARY", + "position": 1, + "columnName": "provenTxId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_txs", + "name": "proven_txs_blockhash_index", + "position": 1, + "columnName": "blockHash", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_txs", + "name": "proven_txs_txid_unique", + "position": 1, + "columnName": "txid", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "PRIMARY", + "position": 1, + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "PRIMARY", + "position": 2, + "columnName": "snapshotFieldName", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "PRIMARY", + "position": 3, + "columnName": "snapshotCertificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_lookup", + "position": 1, + "columnName": "snapshotFieldName", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_lookup", + "position": 2, + "columnName": "snapshotCertificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_lookup", + "position": 3, + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_parent", + "position": 1, + "columnName": "snapshotCertificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_parent", + "position": 2, + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_parent", + "position": 3, + "columnName": "snapshotFieldName", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "PRIMARY", + "position": 1, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "PRIMARY", + "position": 2, + "columnName": "requestId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "PRIMARY", + "position": 3, + "columnName": "tableId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "PRIMARY", + "position": 4, + "columnName": "rowId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "snapshot_global_request", + "position": 1, + "columnName": "requestId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "snapshot_global_request", + "position": 2, + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_guards", + "name": "PRIMARY", + "position": 1, + "columnName": "proofId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "PRIMARY", + "position": 1, + "columnName": "tableId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "PRIMARY", + "position": 2, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "PRIMARY", + "position": 3, + "columnName": "rowId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_page", + "position": 1, + "columnName": "tableId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_page", + "position": 2, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_page", + "position": 3, + "columnName": "present", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_page", + "position": 4, + "columnName": "rowId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_target", + "position": 1, + "columnName": "tableId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_target", + "position": 2, + "columnName": "rowId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_target", + "position": 3, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_profile_keys", + "name": "PRIMARY", + "position": 1, + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_profile_keys", + "name": "PRIMARY", + "position": 2, + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_profile_keys", + "name": "PRIMARY", + "position": 3, + "columnName": "snapshotRowId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "PRIMARY", + "position": 1, + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "PRIMARY", + "position": 2, + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "PRIMARY", + "position": 3, + "columnName": "snapshotLeftId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "PRIMARY", + "position": 4, + "columnName": "snapshotRightId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_map", + "position": 1, + "columnName": "snapshotTableId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_map", + "position": 2, + "columnName": "snapshotLeftId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_map", + "position": 3, + "columnName": "snapshotRightId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_map", + "position": 4, + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_right", + "position": 1, + "columnName": "snapshotTableId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_right", + "position": 2, + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_right", + "position": 3, + "columnName": "snapshotRightId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_right", + "position": 4, + "columnName": "snapshotLeftId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "PRIMARY", + "position": 1, + "columnName": "syncStateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "sync_states_refnum_index", + "position": 1, + "columnName": "refNum", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "sync_states_refnum_unique", + "position": 1, + "columnName": "refNum", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "sync_states_status_index", + "position": 1, + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "sync_states_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_expiry", + "position": 1, + "columnName": "noSendExpiryState", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_expiry", + "position": 2, + "columnName": "noSendExpiryDeadline", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_reclaim", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_reclaim", + "position": 2, + "columnName": "noSendExpiryReclaimTxid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_proven_tx", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_proven_tx", + "position": 2, + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_txid", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_txid", + "position": 2, + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "PRIMARY", + "position": 1, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "transactions_proventxid_foreign", + "position": 1, + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "transactions_reference_unique", + "position": 1, + "columnName": "reference", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "transactions_status_index", + "position": 1, + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "transactions_txid_index", + "position": 1, + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels", + "name": "PRIMARY", + "position": 1, + "columnName": "txLabelId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels", + "name": "tx_labels_label_userid_unique", + "position": 1, + "columnName": "label", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels", + "name": "tx_labels_label_userid_unique", + "position": 2, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels", + "name": "tx_labels_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "idx_tx_labels_map_tx_deleted", + "position": 1, + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "idx_tx_labels_map_tx_deleted", + "position": 2, + "columnName": "isDeleted", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_transactionid_index", + "position": 1, + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_transactionid_unique", + "position": 1, + "columnName": "txLabelId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_transactionid_unique", + "position": 2, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "users", + "name": "PRIMARY", + "position": 1, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "users", + "name": "users_identitykey_unique", + "position": 1, + "columnName": "identityKey", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT TABLE_NAME tableName,CONSTRAINT_NAME name,ORDINAL_POSITION position,COLUMN_NAME columnName,REFERENCED_TABLE_SCHEMA foreignSchema,REFERENCED_TABLE_NAME foreignTable,REFERENCED_COLUMN_NAME foreignColumn FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) AND REFERENCED_TABLE_NAME IS NOT NULL ORDER BY TABLE_NAME,CONSTRAINT_NAME,ORDINAL_POSITION LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields", + "users", + "settings", + "snapshot_profile_keys", + "snapshot_relation_keys", + "snapshot_certificate_field_keys", + "snapshot_global_edges", + "snapshot_global_keys", + "snapshot_global_guards" + ], + "rows": [ + { + "tableName": "certificate_fields", + "name": "certificate_fields_certificateid_foreign", + "position": 1, + "columnName": "certificateId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "certificates", + "foreignColumn": "certificateId" + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "certificates", + "name": "certificates_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_foreign", + "position": 1, + "columnName": "transactionId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "transactions", + "foreignColumn": "transactionId" + }, + { + "tableName": "commissions", + "name": "commissions_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "output_baskets", + "name": "output_baskets_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "output_tags", + "name": "output_tags_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputid_foreign", + "position": 1, + "columnName": "outputId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "outputs", + "foreignColumn": "outputId" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_foreign", + "position": 1, + "columnName": "outputTagId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "output_tags", + "foreignColumn": "outputTagId" + }, + { + "tableName": "outputs", + "name": "outputs_basketid_foreign", + "position": 1, + "columnName": "basketId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "output_baskets", + "foreignColumn": "basketId" + }, + { + "tableName": "outputs", + "name": "outputs_spentby_foreign", + "position": 1, + "columnName": "spentBy", + "foreignSchema": "ts569_snapshot", + "foreignTable": "transactions", + "foreignColumn": "transactionId" + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_foreign", + "position": 1, + "columnName": "transactionId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "transactions", + "foreignColumn": "transactionId" + }, + { + "tableName": "outputs", + "name": "outputs_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_proventxid_foreign", + "position": 1, + "columnName": "provenTxId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "proven_txs", + "foreignColumn": "provenTxId" + }, + { + "tableName": "sync_states", + "name": "sync_states_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "transactions", + "name": "transactions_proventxid_foreign", + "position": 1, + "columnName": "provenTxId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "proven_txs", + "foreignColumn": "provenTxId" + }, + { + "tableName": "transactions", + "name": "transactions_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "tx_labels", + "name": "tx_labels_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_transactionid_foreign", + "position": 1, + "columnName": "transactionId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "transactions", + "foreignColumn": "transactionId" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_foreign", + "position": 1, + "columnName": "txLabelId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "tx_labels", + "foreignColumn": "txLabelId" + } + ] + }, + { + "sql": "SELECT TABLE_NAME tableName,CONSTRAINT_NAME name,MATCH_OPTION matchOption,UPDATE_RULE updateRule,DELETE_RULE deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ORDER BY TABLE_NAME,CONSTRAINT_NAME LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields", + "users", + "settings", + "snapshot_profile_keys", + "snapshot_relation_keys", + "snapshot_certificate_field_keys", + "snapshot_global_edges", + "snapshot_global_keys", + "snapshot_global_guards" + ], + "rows": [ + { + "tableName": "certificate_fields", + "name": "certificate_fields_certificateid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "certificates", + "name": "certificates_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "commissions", + "name": "commissions_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "output_baskets", + "name": "output_baskets_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "output_tags", + "name": "output_tags_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "outputs", + "name": "outputs_basketid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "outputs", + "name": "outputs_spentby_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "outputs", + "name": "outputs_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_proventxid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "sync_states", + "name": "sync_states_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "transactions", + "name": "transactions_proventxid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "transactions", + "name": "transactions_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "tx_labels", + "name": "tx_labels_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_transactionid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + } + ] + }, + { + "sql": "SELECT t.TABLE_NAME tableName,t.CONSTRAINT_NAME name,t.ENFORCED enforced,SUBSTRING(c.CHECK_CLAUSE,1,16385) clause FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) AND t.CONSTRAINT_TYPE='CHECK' ORDER BY t.TABLE_NAME,t.CONSTRAINT_NAME LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields", + "users", + "settings", + "snapshot_profile_keys", + "snapshot_relation_keys", + "snapshot_certificate_field_keys", + "snapshot_global_edges", + "snapshot_global_keys", + "snapshot_global_guards" + ], + "rows": [] + }, + { + "sql": "SELECT EVENT_OBJECT_TABLE tableName,TRIGGER_NAME name,EVENT_MANIPULATION event,ACTION_TIMING timing,ACTION_ORDER actionOrder,DEFINER definer,SUBSTRING(ACTION_STATEMENT,1,16385) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION connectionCollation,DATABASE_COLLATION databaseCollation FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) AND TRIGGER_NAME NOT IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ORDER BY EVENT_OBJECT_TABLE,TRIGGER_NAME LIMIT 513", + "bindings": [ + "transactions", + "outputs", + "certificates", + "tx_labels", + "output_baskets", + "output_tags", + "commissions", + "sync_states", + "proven_txs", + "proven_tx_reqs", + "tx_labels_map", + "output_tags_map", + "certificate_fields", + "users", + "settings", + "snapshot_profile_keys", + "snapshot_relation_keys", + "snapshot_certificate_field_keys", + "snapshot_global_edges", + "snapshot_global_keys", + "snapshot_global_guards", + "snapshot_journal_scope_0_INSERT", + "snapshot_journal_scope_0_UPDATE", + "snapshot_journal_scope_0_DELETE", + "snapshot_journal_scope_1_INSERT", + "snapshot_journal_scope_1_UPDATE", + "snapshot_journal_scope_1_DELETE", + "snapshot_journal_scope_2_INSERT", + "snapshot_journal_scope_2_UPDATE", + "snapshot_journal_scope_2_DELETE", + "snapshot_journal_scope_3_INSERT", + "snapshot_journal_scope_3_UPDATE", + "snapshot_journal_scope_3_DELETE", + "snapshot_journal_physical_0_INSERT", + "snapshot_journal_physical_0_UPDATE", + "snapshot_journal_physical_0_DELETE", + "snapshot_journal_physical_1_INSERT", + "snapshot_journal_physical_1_UPDATE", + "snapshot_journal_physical_1_DELETE", + "snapshot_journal_physical_2_INSERT", + "snapshot_journal_physical_2_UPDATE", + "snapshot_journal_physical_2_DELETE", + "snapshot_journal_physical_3_INSERT", + "snapshot_journal_physical_3_UPDATE", + "snapshot_journal_physical_3_DELETE", + "snapshot_journal_physical_4_INSERT", + "snapshot_journal_physical_4_UPDATE", + "snapshot_journal_physical_4_DELETE", + "snapshot_journal_physical_5_INSERT", + "snapshot_journal_physical_5_UPDATE", + "snapshot_journal_physical_5_DELETE", + "snapshot_journal_physical_6_INSERT", + "snapshot_journal_physical_6_UPDATE", + "snapshot_journal_physical_6_DELETE", + "snapshot_journal_physical_7_INSERT", + "snapshot_journal_physical_7_UPDATE", + "snapshot_journal_physical_7_DELETE", + "snapshot_journal_physical_8_INSERT", + "snapshot_journal_physical_8_UPDATE", + "snapshot_journal_physical_8_DELETE", + "snapshot_journal_physical_9_INSERT", + "snapshot_journal_physical_9_UPDATE", + "snapshot_journal_physical_9_DELETE", + "snapshot_journal_physical_10_INSERT", + "snapshot_journal_physical_10_UPDATE", + "snapshot_journal_physical_10_DELETE", + "snapshot_journal_physical_11_INSERT", + "snapshot_journal_physical_11_UPDATE", + "snapshot_journal_physical_11_DELETE", + "snapshot_journal_physical_12_INSERT", + "snapshot_journal_physical_12_UPDATE", + "snapshot_journal_physical_12_DELETE" + ], + "rows": [ + { + "tableName": "certificate_fields", + "name": "snapshot_certificate_field_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificate_fields", + "name": "snapshot_certificate_field_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificate_fields", + "name": "snapshot_certificate_field_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificate_fields", + "name": "snapshot_certificate_field_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_certificate_parent_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_certificate_parent_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_certificate_parent_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_certificate_parent_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_profile_2_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_profile_2_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_profile_2_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "commissions", + "name": "snapshot_profile_6_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "commissions", + "name": "snapshot_profile_6_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "commissions", + "name": "snapshot_profile_6_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.commissionId <=> NEW.commissionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_baskets", + "name": "snapshot_profile_4_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_baskets", + "name": "snapshot_profile_4_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_baskets", + "name": "snapshot_profile_4_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.basketId <=> NEW.basketId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_profile_5_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_profile_5_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_profile_5_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputTagId <=> NEW.outputTagId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_relation_1_left_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_relation_1_left_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_relation_1_left_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_relation_1_left_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags_map", + "name": "snapshot_relation_1_map_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags_map", + "name": "snapshot_relation_1_map_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags_map", + "name": "snapshot_relation_1_map_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags_map", + "name": "snapshot_relation_1_map_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_profile_1_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_profile_1_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_profile_1_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_relation_1_right_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_relation_1_right_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_relation_1_right_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_relation_1_right_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_tx_reqs", + "name": "snapshot_global_req_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_tx_reqs", + "name": "snapshot_global_req_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_tx_reqs", + "name": "snapshot_global_req_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_tx_reqs", + "name": "snapshot_global_req_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_txs", + "name": "snapshot_global_proof_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_txs", + "name": "snapshot_global_proof_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_txs", + "name": "snapshot_global_proof_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_txs", + "name": "snapshot_global_proof_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "snapshot_global_edges", + "name": "snapshot_global_edge_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_global_keys SET refs=refs-1 WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId; DELETE FROM snapshot_global_keys WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId AND refs=0; DELETE FROM snapshot_global_guards WHERE proofId=OLD.rowId AND OLD.tableId=1 AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.rowId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "snapshot_global_edges", + "name": "snapshot_global_edge_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_global_keys (tableId,userId,rowId,refs,present) VALUES (NEW.tableId,NEW.userId,NEW.rowId,1,CASE WHEN NEW.tableId=0 THEN 1 ELSE (SELECT present FROM snapshot_global_guards WHERE proofId=NEW.rowId FOR SHARE) END) ON DUPLICATE KEY UPDATE refs=snapshot_global_keys.refs+1; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "sync_states", + "name": "snapshot_profile_7_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "sync_states", + "name": "snapshot_profile_7_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "sync_states", + "name": "snapshot_profile_7_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.syncStateId <=> NEW.syncStateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_global_tx_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 3, + "definer": "root@%", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_global_tx_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_global_tx_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 3, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_global_tx_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 3, + "definer": "root@%", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_profile_0_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_profile_0_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_profile_0_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_relation_0_right_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_relation_0_right_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_relation_0_right_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_relation_0_right_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_profile_3_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_profile_3_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_profile_3_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txLabelId <=> NEW.txLabelId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_relation_0_left_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_relation_0_left_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_relation_0_left_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_relation_0_left_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels_map", + "name": "snapshot_relation_0_map_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels_map", + "name": "snapshot_relation_0_map_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels_map", + "name": "snapshot_relation_0_map_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels_map", + "name": "snapshot_relation_0_map_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "users", + "name": "snapshot_sync_primary_change", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.activeStorage <=> NEW.activeStorage) THEN\n INSERT INTO snapshot_sync_primary_epochs (userId, epoch) VALUES (NEW.userId, 1)\n ON DUPLICATE KEY UPDATE epoch = epoch + 1;\n END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + } + ] + }, + { + "sql": "SELECT @@sql_mode sqlMode,@@character_set_client charset,@@collation_connection collation,@@collation_database databaseCollation,CURRENT_USER() definer", + "bindings": [], + "rows": [ + { + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "transactions" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "transactionId" + }, + { + "name": "userId" + }, + { + "name": "provenTxId" + }, + { + "name": "status" + }, + { + "name": "reference" + }, + { + "name": "isOutgoing" + }, + { + "name": "satoshis" + }, + { + "name": "version" + }, + { + "name": "lockTime" + }, + { + "name": "description" + }, + { + "name": "txid" + }, + { + "name": "inputBEEF" + }, + { + "name": "rawTx" + }, + { + "name": "noSendExpiryMode" + }, + { + "name": "noSendExpiryValue" + }, + { + "name": "noSendExpiryDeadline" + }, + { + "name": "noSendExpiryState" + }, + { + "name": "noSendExpiryAnchorTxid" + }, + { + "name": "noSendExpiryAnchorVout" + }, + { + "name": "noSendExpiryReleasedAt" + }, + { + "name": "noSendExpiryObservedAt" + }, + { + "name": "noSendExpiryReclaimTxid" + }, + { + "name": "noSendExpiryReclaimRawTx" + }, + { + "name": "noSendExpiryReclaimDerivationPrefix" + }, + { + "name": "noSendExpiryReclaimDerivationSuffix" + }, + { + "name": "noSendExpiryReclaimSatoshis" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "outputs" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "outputId" + }, + { + "name": "userId" + }, + { + "name": "transactionId" + }, + { + "name": "basketId" + }, + { + "name": "spendable" + }, + { + "name": "change" + }, + { + "name": "vout" + }, + { + "name": "satoshis" + }, + { + "name": "providedBy" + }, + { + "name": "purpose" + }, + { + "name": "type" + }, + { + "name": "outputDescription" + }, + { + "name": "txid" + }, + { + "name": "senderIdentityKey" + }, + { + "name": "derivationPrefix" + }, + { + "name": "derivationSuffix" + }, + { + "name": "customInstructions" + }, + { + "name": "spentBy" + }, + { + "name": "sequenceNumber" + }, + { + "name": "spendingDescription" + }, + { + "name": "scriptLength" + }, + { + "name": "scriptOffset" + }, + { + "name": "lockingScript" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "certificates" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "certificateId" + }, + { + "name": "userId" + }, + { + "name": "serialNumber" + }, + { + "name": "type" + }, + { + "name": "certifier" + }, + { + "name": "subject" + }, + { + "name": "verifier" + }, + { + "name": "revocationOutpoint" + }, + { + "name": "signature" + }, + { + "name": "isDeleted" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "tx_labels" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "txLabelId" + }, + { + "name": "userId" + }, + { + "name": "label" + }, + { + "name": "isDeleted" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "output_baskets" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "basketId" + }, + { + "name": "userId" + }, + { + "name": "name" + }, + { + "name": "numberOfDesiredUTXOs" + }, + { + "name": "minimumDesiredUTXOValue" + }, + { + "name": "isDeleted" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "output_tags" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "outputTagId" + }, + { + "name": "userId" + }, + { + "name": "tag" + }, + { + "name": "isDeleted" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "commissions" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "commissionId" + }, + { + "name": "userId" + }, + { + "name": "transactionId" + }, + { + "name": "satoshis" + }, + { + "name": "keyOffset" + }, + { + "name": "isRedeemed" + }, + { + "name": "lockingScript" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "sync_states" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "syncStateId" + }, + { + "name": "userId" + }, + { + "name": "storageIdentityKey" + }, + { + "name": "storageName" + }, + { + "name": "status" + }, + { + "name": "init" + }, + { + "name": "refNum" + }, + { + "name": "syncMap" + }, + { + "name": "when" + }, + { + "name": "satoshis" + }, + { + "name": "errorLocal" + }, + { + "name": "errorOther" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "proven_txs" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "provenTxId" + }, + { + "name": "txid" + }, + { + "name": "height" + }, + { + "name": "index" + }, + { + "name": "merklePath" + }, + { + "name": "rawTx" + }, + { + "name": "blockHash" + }, + { + "name": "merkleRoot" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "proven_tx_reqs" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "provenTxReqId" + }, + { + "name": "provenTxId" + }, + { + "name": "status" + }, + { + "name": "attempts" + }, + { + "name": "notified" + }, + { + "name": "txid" + }, + { + "name": "batch" + }, + { + "name": "history" + }, + { + "name": "notify" + }, + { + "name": "rawTx" + }, + { + "name": "inputBEEF" + }, + { + "name": "wasBroadcast" + }, + { + "name": "rebroadcastAttempts" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "tx_labels_map" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "txLabelId" + }, + { + "name": "transactionId" + }, + { + "name": "isDeleted" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "output_tags_map" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "outputTagId" + }, + { + "name": "outputId" + }, + { + "name": "isDeleted" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", + "bindings": [ + "certificate_fields" + ], + "rows": [ + { + "name": "created_at" + }, + { + "name": "updated_at" + }, + { + "name": "userId" + }, + { + "name": "certificateId" + }, + { + "name": "fieldName" + }, + { + "name": "fieldValue" + }, + { + "name": "masterKey" + } + ] + }, + { + "sql": "SELECT TABLE_NAME name,TABLE_TYPE type FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LOWER(LEFT(TABLE_NAME,17))='snapshot_journal_' ORDER BY TABLE_NAME LIMIT 130", + "bindings": [], + "rows": [ + { + "name": "snapshot_journal_bootstrap", + "type": "BASE TABLE" + }, + { + "name": "snapshot_journal_clock", + "type": "BASE TABLE" + }, + { + "name": "snapshot_journal_events", + "type": "BASE TABLE" + }, + { + "name": "snapshot_journal_generation", + "type": "BASE TABLE" + }, + { + "name": "snapshot_journal_invalid", + "type": "BASE TABLE" + }, + { + "name": "snapshot_journal_physical", + "type": "BASE TABLE" + }, + { + "name": "snapshot_journal_scope", + "type": "BASE TABLE" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,'TRIGGER' type FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND LOWER(LEFT(TRIGGER_NAME,17))='snapshot_journal_' ORDER BY TRIGGER_NAME LIMIT 130", + "bindings": [], + "rows": [ + { + "name": "snapshot_journal_physical_0_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_0_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_0_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_10_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_10_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_10_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_11_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_11_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_11_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_12_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_12_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_12_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_1_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_1_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_1_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_2_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_2_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_2_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_3_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_3_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_3_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_4_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_4_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_4_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_5_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_5_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_5_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_6_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_6_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_6_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_7_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_7_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_7_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_8_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_8_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_8_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_9_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_9_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_physical_9_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_0_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_0_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_0_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_1_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_1_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_1_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_2_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_2_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_2_UPDATE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_3_DELETE", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_3_INSERT", + "type": "TRIGGER" + }, + { + "name": "snapshot_journal_scope_3_UPDATE", + "type": "TRIGGER" + } + ] + }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_journal_generation" + ], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "ascii_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": [ + "snapshot_journal_generation" + ], + "rows": [ + { + "name": "id", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "version", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "epoch", + "type": "varchar(36)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "source", + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "plan", + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "ceiling", + "type": "varchar(19)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "nextObject", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "complete", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 2", + "bindings": [ + "snapshot_journal_generation" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "id", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES" + } + ] + }, + { + "sql": "SELECT CONSTRAINT_NAME name,CONSTRAINT_TYPE type,ENFORCED enforced FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? LIMIT 2", + "bindings": [ + "snapshot_journal_generation" + ], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": [ + "snapshot_journal_generation" + ], + "rows": [] + }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_journal_clock" + ], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC", + "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": [ + "snapshot_journal_clock" + ], + "rows": [ + { + "name": "id", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "ceiling", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", + "bindings": [ + "snapshot_journal_clock" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "id", + "position": 1, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", + "bindings": [ + "snapshot_journal_clock" + ], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES", + "clause": null + }, + { + "name": "snapshot_journal_clock_chk_1", + "type": "CHECK", + "enforced": "YES", + "clause": "(`id` = 1)" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": [ + "snapshot_journal_clock" + ], + "rows": [] + }, + { + "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", + "bindings": [ + "snapshot_journal_clock" + ], + "rows": [] + }, + { + "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", + "bindings": [ + "snapshot_journal_clock" + ], + "rows": [] + }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_journal_events" + ], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC", + "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": [ + "snapshot_journal_events" + ], + "rows": [ + { + "name": "revision", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", + "bindings": [ + "snapshot_journal_events" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "revision", + "position": 1, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", + "bindings": [ + "snapshot_journal_events" + ], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES", + "clause": null + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": [ + "snapshot_journal_events" + ], + "rows": [] + }, + { + "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", + "bindings": [ + "snapshot_journal_events" + ], + "rows": [] + }, + { + "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", + "bindings": [ + "snapshot_journal_events" + ], + "rows": [] + }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_journal_invalid" + ], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC", + "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": [ + "snapshot_journal_invalid" + ], + "rows": [ + { + "name": "id", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "reason", + "type": "varchar(32)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", + "bindings": [ + "snapshot_journal_invalid" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "id", + "position": 1, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", + "bindings": [ + "snapshot_journal_invalid" + ], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES", + "clause": null + }, + { + "name": "snapshot_journal_invalid_chk_1", + "type": "CHECK", + "enforced": "YES", + "clause": "(`id` = 1)" + }, + { + "name": "snapshot_journal_invalid_chk_2", + "type": "CHECK", + "enforced": "YES", + "clause": "(`reason` in (_utf8mb4\\'capacity-exhausted\\',_utf8mb4\\'revision-exhausted\\',_utf8mb4\\'key-out-of-range\\'))" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": [ + "snapshot_journal_invalid" + ], + "rows": [] + }, + { + "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", + "bindings": [ + "snapshot_journal_invalid" + ], + "rows": [] + }, + { + "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", + "bindings": [ + "snapshot_journal_invalid" + ], + "rows": [] + }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_journal_physical" + ], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC", + "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": [ + "snapshot_journal_physical" + ], + "rows": [ + { + "name": "tableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "id1", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "id2", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "exactText", + "type": "varbinary(400)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "revision", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "generation", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "present", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", + "bindings": [ + "snapshot_journal_physical" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "tableId", + "position": 1, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "PRIMARY", + "columnName": "id1", + "position": 2, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "PRIMARY", + "columnName": "id2", + "position": 3, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "PRIMARY", + "columnName": "exactText", + "position": 4, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_physical_page", + "columnName": "tableId", + "position": 1, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_physical_page", + "columnName": "revision", + "position": 2, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_physical_page", + "columnName": "id1", + "position": 3, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_physical_page", + "columnName": "id2", + "position": 4, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_physical_page", + "columnName": "exactText", + "position": 5, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", + "bindings": [ + "snapshot_journal_physical" + ], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES", + "clause": null + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": [ + "snapshot_journal_physical" + ], + "rows": [] + }, + { + "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", + "bindings": [ + "snapshot_journal_physical" + ], + "rows": [] + }, + { + "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", + "bindings": [ + "snapshot_journal_physical" + ], + "rows": [] + }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_journal_scope" + ], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC", + "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": [ + "snapshot_journal_scope" + ], + "rows": [ + { + "name": "tableId", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "userId", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "id1", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "id2", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "exactText", + "type": "varbinary(400)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "revision", + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "present", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", + "bindings": [ + "snapshot_journal_scope" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "tableId", + "position": 1, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "PRIMARY", + "columnName": "userId", + "position": 2, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "PRIMARY", + "columnName": "id1", + "position": 3, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "PRIMARY", + "columnName": "id2", + "position": 4, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "PRIMARY", + "columnName": "exactText", + "position": 5, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_scope_page", + "columnName": "userId", + "position": 1, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_scope_page", + "columnName": "tableId", + "position": 2, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_scope_page", + "columnName": "revision", + "position": 3, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_scope_page", + "columnName": "id1", + "position": 4, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_scope_page", + "columnName": "id2", + "position": 5, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_scope_page", + "columnName": "exactText", + "position": 6, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", + "bindings": [ + "snapshot_journal_scope" + ], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES", + "clause": null + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": [ + "snapshot_journal_scope" + ], + "rows": [] + }, + { + "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", + "bindings": [ + "snapshot_journal_scope" + ], + "rows": [] + }, + { + "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", + "bindings": [ + "snapshot_journal_scope" + ], + "rows": [] + }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": [ + "snapshot_journal_bootstrap" + ], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC", + "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": [ + "snapshot_journal_bootstrap" + ], + "rows": [ + { + "name": "id", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "stream", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "cursor", + "type": "text", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", + "bindings": [ + "snapshot_journal_bootstrap" + ], + "rows": [ + { + "name": "PRIMARY", + "columnName": "id", + "position": 1, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", + "bindings": [ + "snapshot_journal_bootstrap" + ], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES", + "clause": null + }, + { + "name": "snapshot_journal_bootstrap_chk_1", + "type": "CHECK", + "enforced": "YES", + "clause": "(`id` = 1)" + }, + { + "name": "snapshot_journal_bootstrap_chk_2", + "type": "CHECK", + "enforced": "YES", + "clause": "(`stream` between 0 and 17)" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": [ + "snapshot_journal_bootstrap" + ], + "rows": [] + }, + { + "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", + "bindings": [ + "snapshot_journal_bootstrap" + ], + "rows": [] + }, + { + "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", + "bindings": [ + "snapshot_journal_bootstrap" + ], + "rows": [] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1444, + "snapshot_journal_scope_0_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_scope_0_INSERT", + "table": "snapshot_profile_keys", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1444, + "snapshot_journal_scope_0_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_scope_0_DELETE", + "table": "snapshot_profile_keys", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1674, + "snapshot_journal_scope_0_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_scope_0_UPDATE", + "table": "snapshot_profile_keys", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1511, + "snapshot_journal_scope_1_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_scope_1_INSERT", + "table": "snapshot_relation_keys", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1485, + "snapshot_journal_scope_1_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_scope_1_DELETE", + "table": "snapshot_relation_keys", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1837, + "snapshot_journal_scope_1_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_scope_1_UPDATE", + "table": "snapshot_relation_keys", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1507, + "snapshot_journal_scope_2_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_scope_2_INSERT", + "table": "snapshot_certificate_field_keys", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1481, + "snapshot_journal_scope_2_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_scope_2_DELETE", + "table": "snapshot_certificate_field_keys", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1809, + "snapshot_journal_scope_2_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_scope_2_UPDATE", + "table": "snapshot_certificate_field_keys", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1444, + "snapshot_journal_scope_3_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_scope_3_INSERT", + "table": "snapshot_global_keys", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1434, + "snapshot_journal_scope_3_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_scope_3_DELETE", + "table": "snapshot_global_keys", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1706, + "snapshot_journal_scope_3_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_scope_3_UPDATE", + "table": "snapshot_global_keys", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1737, + "snapshot_journal_physical_0_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_0_INSERT", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 5134, + "snapshot_journal_physical_0_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_0_UPDATE", + "table": "transactions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1498, + "snapshot_journal_physical_0_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_0_DELETE", + "table": "transactions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1722, + "snapshot_journal_physical_1_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_1_INSERT", + "table": "outputs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 4592, + "snapshot_journal_physical_1_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_1_UPDATE", + "table": "outputs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1488, + "snapshot_journal_physical_1_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_1_DELETE", + "table": "outputs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1737, + "snapshot_journal_physical_2_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_2_INSERT", + "table": "certificates", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3564, + "snapshot_journal_physical_2_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_2_UPDATE", + "table": "certificates", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1498, + "snapshot_journal_physical_2_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_2_DELETE", + "table": "certificates", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1725, + "snapshot_journal_physical_3_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_3_INSERT", + "table": "tx_labels", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3042, + "snapshot_journal_physical_3_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_3_UPDATE", + "table": "tx_labels", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1490, + "snapshot_journal_physical_3_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_3_DELETE", + "table": "tx_labels", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1722, + "snapshot_journal_physical_4_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_4_INSERT", + "table": "output_baskets", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3233, + "snapshot_journal_physical_4_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_4_UPDATE", + "table": "output_baskets", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1488, + "snapshot_journal_physical_4_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_4_DELETE", + "table": "output_baskets", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1731, + "snapshot_journal_physical_5_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_5_INSERT", + "table": "output_tags", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3060, + "snapshot_journal_physical_5_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_5_UPDATE", + "table": "output_tags", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1494, + "snapshot_journal_physical_5_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_5_DELETE", + "table": "output_tags", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1734, + "snapshot_journal_physical_6_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_6_INSERT", + "table": "commissions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3330, + "snapshot_journal_physical_6_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_6_UPDATE", + "table": "commissions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1496, + "snapshot_journal_physical_6_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_6_DELETE", + "table": "commissions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1731, + "snapshot_journal_physical_7_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_7_INSERT", + "table": "sync_states", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3676, + "snapshot_journal_physical_7_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_7_UPDATE", + "table": "sync_states", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1494, + "snapshot_journal_physical_7_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_7_DELETE", + "table": "sync_states", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1443, + "snapshot_journal_physical_8_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_8_INSERT", + "table": "proven_txs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3014, + "snapshot_journal_physical_8_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_8_UPDATE", + "table": "proven_txs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1444, + "snapshot_journal_physical_8_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_8_DELETE", + "table": "proven_txs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1449, + "snapshot_journal_physical_9_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_9_INSERT", + "table": "proven_tx_reqs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3439, + "snapshot_journal_physical_9_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_9_UPDATE", + "table": "proven_tx_reqs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1450, + "snapshot_journal_physical_9_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_9_DELETE", + "table": "proven_tx_reqs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1883, + "snapshot_journal_physical_10_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_10_INSERT", + "table": "tx_labels_map", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3182, + "snapshot_journal_physical_10_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_10_UPDATE", + "table": "tx_labels_map", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1471, + "snapshot_journal_physical_10_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_10_DELETE", + "table": "tx_labels_map", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1874, + "snapshot_journal_physical_11_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_11_INSERT", + "table": "output_tags_map", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3149, + "snapshot_journal_physical_11_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_11_UPDATE", + "table": "output_tags_map", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1465, + "snapshot_journal_physical_11_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_11_DELETE", + "table": "output_tags_map", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1885, + "snapshot_journal_physical_12_INSERT" + ], + "rows": [ + { + "name": "snapshot_journal_physical_12_INSERT", + "table": "certificate_fields", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 3328, + "snapshot_journal_physical_12_UPDATE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_12_UPDATE", + "table": "certificate_fields", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", + "bindings": [ + 1469, + "snapshot_journal_physical_12_DELETE" + ], + "rows": [ + { + "name": "snapshot_journal_physical_12_DELETE", + "table": "certificate_fields", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci", + "definer": "root@%" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "transactions" + ], + "rows": [ + { + "name": "idx_transactions_nosend_expiry", + "field": "noSendExpiryState" + }, + { + "name": "idx_transactions_nosend_expiry", + "field": "noSendExpiryDeadline" + }, + { + "name": "idx_transactions_nosend_reclaim", + "field": "userId" + }, + { + "name": "idx_transactions_nosend_reclaim", + "field": "noSendExpiryReclaimTxid" + }, + { + "name": "idx_transactions_user_proven_tx", + "field": "userId" + }, + { + "name": "idx_transactions_user_proven_tx", + "field": "provenTxId" + }, + { + "name": "idx_transactions_user_txid", + "field": "userId" + }, + { + "name": "idx_transactions_user_txid", + "field": "txid" + }, + { + "name": "PRIMARY", + "field": "transactionId" + }, + { + "name": "transactions_proventxid_foreign", + "field": "provenTxId" + }, + { + "name": "transactions_reference_unique", + "field": "reference" + }, + { + "name": "transactions_status_index", + "field": "status" + }, + { + "name": "transactions_txid_index", + "field": "txid" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "outputs" + ], + "rows": [ + { + "name": "idx_outputs_funding_selection", + "field": "userId" + }, + { + "name": "idx_outputs_funding_selection", + "field": "basketId" + }, + { + "name": "idx_outputs_funding_selection", + "field": "spendable" + }, + { + "name": "idx_outputs_funding_selection", + "field": "spentBy" + }, + { + "name": "idx_outputs_funding_selection", + "field": "satoshis" + }, + { + "name": "idx_outputs_funding_selection", + "field": "outputId" + }, + { + "name": "idx_outputs_spentby", + "field": "spentBy" + }, + { + "name": "idx_outputs_user_basket_spendable_outputid", + "field": "userId" + }, + { + "name": "idx_outputs_user_basket_spendable_outputid", + "field": "basketId" + }, + { + "name": "idx_outputs_user_basket_spendable_outputid", + "field": "spendable" + }, + { + "name": "idx_outputs_user_basket_spendable_outputid", + "field": "outputId" + }, + { + "name": "idx_outputs_user_basket_spendable_satoshis", + "field": "userId" + }, + { + "name": "idx_outputs_user_basket_spendable_satoshis", + "field": "basketId" + }, + { + "name": "idx_outputs_user_basket_spendable_satoshis", + "field": "spendable" + }, + { + "name": "idx_outputs_user_basket_spendable_satoshis", + "field": "satoshis" + }, + { + "name": "idx_outputs_user_spendable_outputid", + "field": "userId" + }, + { + "name": "idx_outputs_user_spendable_outputid", + "field": "spendable" + }, + { + "name": "idx_outputs_user_spendable_outputid", + "field": "outputId" + }, + { + "name": "outputs_basketid_foreign", + "field": "basketId" + }, + { + "name": "outputs_spendable_index", + "field": "spendable" + }, + { + "name": "outputs_transactionid_vout_userid_unique", + "field": "transactionId" + }, + { + "name": "outputs_transactionid_vout_userid_unique", + "field": "vout" + }, + { + "name": "outputs_transactionid_vout_userid_unique", + "field": "userId" + }, + { + "name": "PRIMARY", + "field": "outputId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "certificates" + ], + "rows": [ + { + "name": "certificates_userid_type_certifier_serialnumber_unique", + "field": "userId" + }, + { + "name": "certificates_userid_type_certifier_serialnumber_unique", + "field": "type" + }, + { + "name": "certificates_userid_type_certifier_serialnumber_unique", + "field": "certifier" + }, + { + "name": "certificates_userid_type_certifier_serialnumber_unique", + "field": "serialNumber" + }, + { + "name": "PRIMARY", + "field": "certificateId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "tx_labels" + ], + "rows": [ + { + "name": "PRIMARY", + "field": "txLabelId" + }, + { + "name": "tx_labels_label_userid_unique", + "field": "label" + }, + { + "name": "tx_labels_label_userid_unique", + "field": "userId" + }, + { + "name": "tx_labels_userid_foreign", + "field": "userId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "output_baskets" + ], + "rows": [ + { + "name": "output_baskets_name_userid_unique", + "field": "name" + }, + { + "name": "output_baskets_name_userid_unique", + "field": "userId" + }, + { + "name": "output_baskets_userid_foreign", + "field": "userId" + }, + { + "name": "PRIMARY", + "field": "basketId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "output_tags" + ], + "rows": [ + { + "name": "output_tags_tag_userid_unique", + "field": "tag" + }, + { + "name": "output_tags_tag_userid_unique", + "field": "userId" + }, + { + "name": "output_tags_userid_foreign", + "field": "userId" + }, + { + "name": "PRIMARY", + "field": "outputTagId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "commissions" + ], + "rows": [ + { + "name": "commissions_transactionid_index", + "field": "transactionId" + }, + { + "name": "commissions_transactionid_unique", + "field": "transactionId" + }, + { + "name": "commissions_userid_foreign", + "field": "userId" + }, + { + "name": "PRIMARY", + "field": "commissionId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "sync_states" + ], + "rows": [ + { + "name": "PRIMARY", + "field": "syncStateId" + }, + { + "name": "sync_states_refnum_index", + "field": "refNum" + }, + { + "name": "sync_states_refnum_unique", + "field": "refNum" + }, + { + "name": "sync_states_status_index", + "field": "status" + }, + { + "name": "sync_states_userid_foreign", + "field": "userId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "proven_txs" + ], + "rows": [ + { + "name": "PRIMARY", + "field": "provenTxId" + }, + { + "name": "proven_txs_blockhash_index", + "field": "blockHash" + }, + { + "name": "proven_txs_txid_unique", + "field": "txid" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "proven_tx_reqs" + ], + "rows": [ + { + "name": "PRIMARY", + "field": "provenTxReqId" + }, + { + "name": "proven_tx_reqs_batch_index", + "field": "batch" + }, + { + "name": "proven_tx_reqs_proventxid_foreign", + "field": "provenTxId" + }, + { + "name": "proven_tx_reqs_status_index", + "field": "status" + }, + { + "name": "proven_tx_reqs_txid_index", + "field": "txid" + }, + { + "name": "proven_tx_reqs_txid_unique", + "field": "txid" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "tx_labels_map" + ], + "rows": [ + { + "name": "idx_tx_labels_map_tx_deleted", + "field": "transactionId" + }, + { + "name": "idx_tx_labels_map_tx_deleted", + "field": "isDeleted" + }, + { + "name": "tx_labels_map_transactionid_index", + "field": "transactionId" + }, + { + "name": "tx_labels_map_txlabelid_transactionid_unique", + "field": "txLabelId" + }, + { + "name": "tx_labels_map_txlabelid_transactionid_unique", + "field": "transactionId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "output_tags_map" + ], + "rows": [ + { + "name": "idx_output_tags_map_output_deleted_tag", + "field": "outputId" + }, + { + "name": "idx_output_tags_map_output_deleted_tag", + "field": "isDeleted" + }, + { + "name": "idx_output_tags_map_output_deleted_tag", + "field": "outputTagId" + }, + { + "name": "output_tags_map_outputid_index", + "field": "outputId" + }, + { + "name": "output_tags_map_outputtagid_outputid_unique", + "field": "outputTagId" + }, + { + "name": "output_tags_map_outputtagid_outputid_unique", + "field": "outputId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "certificate_fields" + ], + "rows": [ + { + "name": "certificate_fields_certificateid_foreign", + "field": "certificateId" + }, + { + "name": "certificate_fields_fieldname_certificateid_unique", + "field": "fieldName" + }, + { + "name": "certificate_fields_fieldname_certificateid_unique", + "field": "certificateId" + }, + { + "name": "certificate_fields_userid_foreign", + "field": "userId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "snapshot_profile_keys" + ], + "rows": [ + { + "name": "PRIMARY", + "field": "snapshotTableId" + }, + { + "name": "PRIMARY", + "field": "snapshotUserId" + }, + { + "name": "PRIMARY", + "field": "snapshotRowId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "snapshot_relation_keys" + ], + "rows": [ + { + "name": "PRIMARY", + "field": "snapshotTableId" + }, + { + "name": "PRIMARY", + "field": "snapshotUserId" + }, + { + "name": "PRIMARY", + "field": "snapshotLeftId" + }, + { + "name": "PRIMARY", + "field": "snapshotRightId" + }, + { + "name": "snapshot_relation_map", + "field": "snapshotTableId" + }, + { + "name": "snapshot_relation_map", + "field": "snapshotLeftId" + }, + { + "name": "snapshot_relation_map", + "field": "snapshotRightId" + }, + { + "name": "snapshot_relation_map", + "field": "snapshotUserId" + }, + { + "name": "snapshot_relation_right", + "field": "snapshotTableId" + }, + { + "name": "snapshot_relation_right", + "field": "snapshotUserId" + }, + { + "name": "snapshot_relation_right", + "field": "snapshotRightId" + }, + { + "name": "snapshot_relation_right", + "field": "snapshotLeftId" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "snapshot_certificate_field_keys" + ], + "rows": [ + { + "name": "PRIMARY", + "field": "snapshotUserId" + }, + { + "name": "PRIMARY", + "field": "snapshotFieldName" + }, + { + "name": "PRIMARY", + "field": "snapshotCertificateId" + }, + { + "name": "snapshot_certificate_lookup", + "field": "snapshotFieldName" + }, + { + "name": "snapshot_certificate_lookup", + "field": "snapshotCertificateId" + }, + { + "name": "snapshot_certificate_lookup", + "field": "snapshotUserId" + }, + { + "name": "snapshot_certificate_parent", + "field": "snapshotCertificateId" + }, + { + "name": "snapshot_certificate_parent", + "field": "snapshotUserId" + }, + { + "name": "snapshot_certificate_parent", + "field": "snapshotFieldName" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", + "bindings": [ + "snapshot_global_keys" + ], + "rows": [ + { + "name": "PRIMARY", + "field": "tableId" + }, + { + "name": "PRIMARY", + "field": "userId" + }, + { + "name": "PRIMARY", + "field": "rowId" + }, + { + "name": "snapshot_global_page", + "field": "tableId" + }, + { + "name": "snapshot_global_page", + "field": "userId" + }, + { + "name": "snapshot_global_page", + "field": "present" + }, + { + "name": "snapshot_global_page", + "field": "rowId" + }, + { + "name": "snapshot_global_target", + "field": "tableId" + }, + { + "name": "snapshot_global_target", + "field": "rowId" + }, + { + "name": "snapshot_global_target", + "field": "userId" + } + ] + } +] \ No newline at end of file diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json new file mode 100644 index 000000000..ddf63764d --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json @@ -0,0 +1 @@ +{"id":1,"version":1,"epoch":"c4e903f1-fdd4-4eba-8189-3305defcbf2c","source":"166b30f8f3e2c27b0bdc36a22b1aa86514cd245a3ad0ce84f69c215890a3b2ee","plan":"f3c86bd5f1bd18744033dbe85036e53f667caa741613b58cf985ba8e7628881c","ceiling":"9223372036854775807","nextObject":57,"complete":1} \ No newline at end of file diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-intent-metadata-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-intent-metadata-fixture.json new file mode 100644 index 000000000..62159c24a --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-intent-metadata-fixture.json @@ -0,0 +1,112 @@ +{ + "tables": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "ascii_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC" + } + ], + "columns": [ + { + "name": "id", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "version", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "epoch", + "type": "varchar(36)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "source", + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "plan", + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "ceiling", + "type": "varchar(19)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "nextObject", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "complete", + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + } + ], + "indexes": [ + { + "name": "PRIMARY", + "columnName": "id", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES" + } + ], + "constraints": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES" + } + ], + "triggers": [] +} diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-source-metadata-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-source-metadata-fixture.json new file mode 100644 index 000000000..2cacf9b94 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-source-metadata-fixture.json @@ -0,0 +1,5989 @@ +{ + "sourceTables": [ + { + "name": "certificate_fields", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "certificates", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "commissions", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "output_baskets", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "output_tags", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "output_tags_map", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "outputs", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "proven_tx_reqs", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "proven_txs", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "sync_states", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "transactions", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "tx_labels", + "engine": "InnoDB", + "type": "BASE TABLE" + }, + { + "name": "tx_labels_map", + "engine": "InnoDB", + "type": "BASE TABLE" + } + ], + "sourceRules": [ + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + } + ], + "sourceTriggers": [ + { + "name": "snapshot_profile_0_delete", + "table": "transactions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; END" + }, + { + "name": "snapshot_profile_0_update", + "table": "transactions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END IF; END" + }, + { + "name": "snapshot_profile_0_insert", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END" + }, + { + "name": "snapshot_profile_1_delete", + "table": "outputs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; END" + }, + { + "name": "snapshot_profile_1_update", + "table": "outputs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END IF; END" + }, + { + "name": "snapshot_profile_1_insert", + "table": "outputs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END" + }, + { + "name": "snapshot_profile_2_delete", + "table": "certificates", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; END" + }, + { + "name": "snapshot_profile_2_update", + "table": "certificates", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END IF; END" + }, + { + "name": "snapshot_profile_2_insert", + "table": "certificates", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END" + }, + { + "name": "snapshot_profile_3_delete", + "table": "tx_labels", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; END" + }, + { + "name": "snapshot_profile_3_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txLabelId <=> NEW.txLabelId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END IF; END" + }, + { + "name": "snapshot_profile_3_insert", + "table": "tx_labels", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END" + }, + { + "name": "snapshot_profile_4_delete", + "table": "output_baskets", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; END" + }, + { + "name": "snapshot_profile_4_update", + "table": "output_baskets", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.basketId <=> NEW.basketId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END IF; END" + }, + { + "name": "snapshot_profile_4_insert", + "table": "output_baskets", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END" + }, + { + "name": "snapshot_profile_5_delete", + "table": "output_tags", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; END" + }, + { + "name": "snapshot_profile_5_update", + "table": "output_tags", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputTagId <=> NEW.outputTagId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END IF; END" + }, + { + "name": "snapshot_profile_5_insert", + "table": "output_tags", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END" + }, + { + "name": "snapshot_profile_6_delete", + "table": "commissions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; END" + }, + { + "name": "snapshot_profile_6_update", + "table": "commissions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.commissionId <=> NEW.commissionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END IF; END" + }, + { + "name": "snapshot_profile_6_insert", + "table": "commissions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END" + }, + { + "name": "snapshot_profile_7_delete", + "table": "sync_states", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; END" + }, + { + "name": "snapshot_profile_7_update", + "table": "sync_states", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.syncStateId <=> NEW.syncStateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END IF; END" + }, + { + "name": "snapshot_profile_7_insert", + "table": "sync_states", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END" + }, + { + "name": "snapshot_relation_0_map_delete", + "table": "tx_labels_map", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END" + }, + { + "name": "snapshot_relation_0_map_before_update", + "table": "tx_labels_map", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END IF; END" + }, + { + "name": "snapshot_relation_0_left_delete", + "table": "tx_labels", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_relation_0_left_before_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_relation_0_right_delete", + "table": "transactions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_relation_0_right_before_update", + "table": "transactions", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_relation_1_map_delete", + "table": "output_tags_map", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END" + }, + { + "name": "snapshot_relation_1_map_before_update", + "table": "output_tags_map", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END IF; END" + }, + { + "name": "snapshot_relation_1_left_delete", + "table": "output_tags", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_relation_1_left_before_update", + "table": "output_tags", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_relation_1_right_delete", + "table": "outputs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_relation_1_right_before_update", + "table": "outputs", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_relation_0_map_insert", + "table": "tx_labels_map", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + }, + { + "name": "snapshot_relation_0_map_after_update", + "table": "tx_labels_map", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_relation_0_left_insert", + "table": "tx_labels", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" + }, + { + "name": "snapshot_relation_0_left_after_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" + }, + { + "name": "snapshot_relation_0_right_insert", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + }, + { + "name": "snapshot_relation_0_right_after_update", + "table": "transactions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_relation_1_map_insert", + "table": "output_tags_map", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + }, + { + "name": "snapshot_relation_1_map_after_update", + "table": "output_tags_map", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_relation_1_left_insert", + "table": "output_tags", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" + }, + { + "name": "snapshot_relation_1_left_after_update", + "table": "output_tags", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" + }, + { + "name": "snapshot_relation_1_right_insert", + "table": "outputs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + }, + { + "name": "snapshot_relation_1_right_after_update", + "table": "outputs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_certificate_field_delete", + "table": "certificate_fields", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END" + }, + { + "name": "snapshot_certificate_field_before_update", + "table": "certificate_fields", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END IF; END" + }, + { + "name": "snapshot_certificate_parent_delete", + "table": "certificates", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_certificate_parent_before_update", + "table": "certificates", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_certificate_field_insert", + "table": "certificate_fields", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END" + }, + { + "name": "snapshot_certificate_field_after_update", + "table": "certificate_fields", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_certificate_parent_insert", + "table": "certificates", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END" + }, + { + "name": "snapshot_certificate_parent_after_update", + "table": "certificates", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END" + }, + { + "name": "snapshot_global_edge_delete", + "table": "snapshot_global_edges", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_global_keys SET refs=refs-1 WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId; DELETE FROM snapshot_global_keys WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId AND refs=0; DELETE FROM snapshot_global_guards WHERE proofId=OLD.rowId AND OLD.tableId=1 AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.rowId); END" + }, + { + "name": "snapshot_global_edge_insert", + "table": "snapshot_global_edges", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_global_keys (tableId,userId,rowId,refs,present) VALUES (NEW.tableId,NEW.userId,NEW.rowId,1,CASE WHEN NEW.tableId=0 THEN 1 ELSE (SELECT present FROM snapshot_global_guards WHERE proofId=NEW.rowId FOR SHARE) END) ON DUPLICATE KEY UPDATE refs=snapshot_global_keys.refs+1; END" + }, + { + "name": "snapshot_global_tx_delete", + "table": "transactions", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END" + }, + { + "name": "snapshot_global_tx_before_update", + "table": "transactions", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END IF; END" + }, + { + "name": "snapshot_global_req_delete", + "table": "proven_tx_reqs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END" + }, + { + "name": "snapshot_global_req_before_update", + "table": "proven_tx_reqs", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END IF; END" + }, + { + "name": "snapshot_global_proof_delete", + "table": "proven_txs", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END" + }, + { + "name": "snapshot_global_proof_before_update", + "table": "proven_txs", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END IF; END" + }, + { + "name": "snapshot_global_proof_insert", + "table": "proven_txs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END" + }, + { + "name": "snapshot_global_proof_after_update", + "table": "proven_txs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" + }, + { + "name": "snapshot_global_tx_insert", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END" + }, + { + "name": "snapshot_global_tx_after_update", + "table": "transactions", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END IF; END" + }, + { + "name": "snapshot_global_req_insert", + "table": "proven_tx_reqs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END" + }, + { + "name": "snapshot_global_req_after_update", + "table": "proven_tx_reqs", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" + } + ], + "tables": [ + { + "name": "certificate_fields", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "certificates", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "commissions", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "output_baskets", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "output_tags", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "output_tags_map", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "outputs", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "proven_tx_reqs", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "proven_txs", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "settings", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_certificate_field_keys", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_global_edges", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_global_guards", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_global_keys", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_profile_keys", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "snapshot_relation_keys", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "sync_states", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "transactions", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "tx_labels", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "tx_labels_map", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + }, + { + "name": "users", + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "utf8mb4_0900_ai_ci", + "rowFormat": "Dynamic", + "options": "" + } + ], + "columns": [ + { + "tableName": "certificate_fields", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "userId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "certificateId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "fieldName", + "position": 5, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "fieldValue", + "position": 6, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificate_fields", + "name": "masterKey", + "position": 7, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": "", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificates", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificates", + "name": "certificateId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificates", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "certificates", + "name": "serialNumber", + "position": 5, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "type", + "position": 6, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "certifier", + "position": 7, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "subject", + "position": 8, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "verifier", + "position": 9, + "type": "varchar(100)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "revocationOutpoint", + "position": 10, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "signature", + "position": 11, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "certificates", + "name": "isDeleted", + "position": 12, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "commissionId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "transactionId", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "satoshis", + "position": 6, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "keyOffset", + "position": 7, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "commissions", + "name": "isRedeemed", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "commissions", + "name": "lockingScript", + "position": 9, + "type": "blob", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "basketId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "name", + "position": 5, + "type": "varchar(300)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "numberOfDesiredUTXOs", + "position": 6, + "type": "int", + "nullable": "NO", + "defaultValue": "144", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "minimumDesiredUTXOValue", + "position": 7, + "type": "int", + "nullable": "NO", + "defaultValue": "5000", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_baskets", + "name": "isDeleted", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "outputTagId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags", + "name": "tag", + "position": 5, + "type": "varchar(150)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "output_tags", + "name": "isDeleted", + "position": 6, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "outputTagId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "outputId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "output_tags_map", + "name": "isDeleted", + "position": 5, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "outputId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "transactionId", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "basketId", + "position": 6, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "spendable", + "position": 7, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "change", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "vout", + "position": 9, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "satoshis", + "position": 10, + "type": "bigint", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "providedBy", + "position": 11, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "purpose", + "position": 12, + "type": "varchar(20)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "type", + "position": 13, + "type": "varchar(50)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "outputDescription", + "position": 14, + "type": "varchar(2048)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "txid", + "position": 15, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "senderIdentityKey", + "position": 16, + "type": "varchar(130)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "derivationPrefix", + "position": 17, + "type": "varchar(200)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "derivationSuffix", + "position": 18, + "type": "varchar(200)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "customInstructions", + "position": 19, + "type": "varchar(2500)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "spentBy", + "position": 20, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "sequenceNumber", + "position": 21, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "spendingDescription", + "position": 22, + "type": "varchar(2048)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "outputs", + "name": "scriptLength", + "position": 23, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "scriptOffset", + "position": 24, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "outputs", + "name": "lockingScript", + "position": 25, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "provenTxReqId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "provenTxId", + "position": 4, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "status", + "position": 5, + "type": "varchar(16)", + "nullable": "NO", + "defaultValue": "unknown", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "attempts", + "position": 6, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "notified", + "position": 7, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "txid", + "position": 8, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "batch", + "position": 9, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "history", + "position": 10, + "type": "longtext", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "notify", + "position": 11, + "type": "longtext", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "rawTx", + "position": 12, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "inputBEEF", + "position": 13, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "wasBroadcast", + "position": 14, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_tx_reqs", + "name": "rebroadcastAttempts", + "position": 15, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "provenTxId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "txid", + "position": 4, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "height", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "index", + "position": 6, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "merklePath", + "position": 7, + "type": "blob", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "rawTx", + "position": 8, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "blockHash", + "position": 9, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "proven_txs", + "name": "merkleRoot", + "position": 10, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "settings", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "settings", + "name": "storageIdentityKey", + "position": 3, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "storageName", + "position": 4, + "type": "varchar(128)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "chain", + "position": 5, + "type": "varchar(10)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "dbtype", + "position": 6, + "type": "varchar(10)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "settings", + "name": "maxOutputScript", + "position": 7, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshotUserId", + "position": 1, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshotFieldName", + "position": 2, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshotCertificateId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshotMembership", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "transactionId", + "position": 1, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "requestId", + "position": 2, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "tableId", + "position": 3, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "rowId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_edges", + "name": "userId", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_guards", + "name": "proofId", + "position": 1, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_guards", + "name": "present", + "position": 2, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "tableId", + "position": 1, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "userId", + "position": 2, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "rowId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "refs", + "position": 4, + "type": "bigint unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_global_keys", + "name": "present", + "position": 5, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_profile_keys", + "name": "snapshotTableId", + "position": 1, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_profile_keys", + "name": "snapshotUserId", + "position": 2, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_profile_keys", + "name": "snapshotRowId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotTableId", + "position": 1, + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotUserId", + "position": 2, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotLeftId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotRightId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshotMembership", + "position": 5, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "syncStateId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "storageIdentityKey", + "position": 5, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": "", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "storageName", + "position": 6, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "status", + "position": 7, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": "unknown", + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "init", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "refNum", + "position": 9, + "type": "varchar(100)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "syncMap", + "position": 10, + "type": "longtext", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "when", + "position": 11, + "type": "datetime", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "satoshis", + "position": 12, + "type": "bigint", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "sync_states", + "name": "errorLocal", + "position": 13, + "type": "longtext", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "sync_states", + "name": "errorOther", + "position": 14, + "type": "longtext", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "transactionId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "provenTxId", + "position": 5, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "status", + "position": 6, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "reference", + "position": 7, + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "isOutgoing", + "position": 8, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "satoshis", + "position": 9, + "type": "bigint", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "version", + "position": 10, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "lockTime", + "position": 11, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "description", + "position": 12, + "type": "varchar(2048)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "txid", + "position": 13, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "inputBEEF", + "position": 14, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "rawTx", + "position": 15, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryMode", + "position": 16, + "type": "varchar(16)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryValue", + "position": 17, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryDeadline", + "position": 18, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryState", + "position": 19, + "type": "varchar(24)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryAnchorTxid", + "position": 20, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryAnchorVout", + "position": 21, + "type": "int unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReleasedAt", + "position": 22, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryObservedAt", + "position": 23, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimTxid", + "position": 24, + "type": "varchar(64)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimRawTx", + "position": 25, + "type": "longblob", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimDerivationPrefix", + "position": 26, + "type": "varchar(32)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimDerivationSuffix", + "position": 27, + "type": "varchar(32)", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "transactions", + "name": "noSendExpiryReclaimSatoshis", + "position": 28, + "type": "bigint unsigned", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "txLabelId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "userId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "label", + "position": 5, + "type": "varchar(300)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "tx_labels", + "name": "isDeleted", + "position": 6, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "txLabelId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "transactionId", + "position": 4, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "tx_labels_map", + "name": "isDeleted", + "position": 5, + "type": "tinyint(1)", + "nullable": "NO", + "defaultValue": "0", + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "users", + "name": "created_at", + "position": 1, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "users", + "name": "updated_at", + "position": 2, + "type": "timestamp(3)", + "nullable": "NO", + "defaultValue": "CURRENT_TIMESTAMP(3)", + "extra": "DEFAULT_GENERATED", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "users", + "name": "userId", + "position": 3, + "type": "int unsigned", + "nullable": "NO", + "defaultValue": null, + "extra": "auto_increment", + "charset": null, + "collation": null, + "expression": "" + }, + { + "tableName": "users", + "name": "identityKey", + "position": 4, + "type": "varchar(130)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + }, + { + "tableName": "users", + "name": "activeStorage", + "position": 5, + "type": "varchar(255)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci", + "expression": "" + } + ], + "indexes": [ + { + "tableName": "certificate_fields", + "name": "certificate_fields_certificateid_foreign", + "position": 1, + "columnName": "certificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_fieldname_certificateid_unique", + "position": 1, + "columnName": "fieldName", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_fieldname_certificateid_unique", + "position": 2, + "columnName": "certificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "position": 1, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "position": 2, + "columnName": "type", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "position": 3, + "columnName": "certifier", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "certificates_userid_type_certifier_serialnumber_unique", + "position": 4, + "columnName": "serialNumber", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "certificates", + "name": "PRIMARY", + "position": 1, + "columnName": "certificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_index", + "position": 1, + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_unique", + "position": 1, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "commissions", + "name": "commissions_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "commissions", + "name": "PRIMARY", + "position": 1, + "columnName": "commissionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_baskets", + "name": "output_baskets_name_userid_unique", + "position": 1, + "columnName": "name", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_baskets", + "name": "output_baskets_name_userid_unique", + "position": 2, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_baskets", + "name": "output_baskets_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_baskets", + "name": "PRIMARY", + "position": 1, + "columnName": "basketId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags", + "name": "output_tags_tag_userid_unique", + "position": 1, + "columnName": "tag", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags", + "name": "output_tags_tag_userid_unique", + "position": 2, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags", + "name": "output_tags_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags", + "name": "PRIMARY", + "position": 1, + "columnName": "outputTagId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "position": 1, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "position": 2, + "columnName": "isDeleted", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "idx_output_tags_map_output_deleted_tag", + "position": 3, + "columnName": "outputTagId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputid_index", + "position": 1, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_outputid_unique", + "position": 1, + "columnName": "outputTagId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_outputid_unique", + "position": 2, + "columnName": "outputId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 2, + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 3, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 4, + "columnName": "spentBy", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 5, + "columnName": "satoshis", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_funding_selection", + "position": 6, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_spentby", + "position": 1, + "columnName": "spentBy", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "position": 2, + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "position": 3, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_outputid", + "position": 4, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "position": 2, + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "position": 3, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_basket_spendable_satoshis", + "position": 4, + "columnName": "satoshis", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "position": 2, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "idx_outputs_user_spendable_outputid", + "position": 3, + "columnName": "outputId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_basketid_foreign", + "position": 1, + "columnName": "basketId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_spendable_index", + "position": 1, + "columnName": "spendable", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "position": 1, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "position": 2, + "columnName": "vout", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_vout_userid_unique", + "position": 3, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "outputs", + "name": "PRIMARY", + "position": 1, + "columnName": "outputId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "PRIMARY", + "position": 1, + "columnName": "provenTxReqId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_batch_index", + "position": 1, + "columnName": "batch", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_proventxid_foreign", + "position": 1, + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_status_index", + "position": 1, + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_txid_index", + "position": 1, + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_txid_unique", + "position": 1, + "columnName": "txid", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_txs", + "name": "PRIMARY", + "position": 1, + "columnName": "provenTxId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_txs", + "name": "proven_txs_blockhash_index", + "position": 1, + "columnName": "blockHash", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "proven_txs", + "name": "proven_txs_txid_unique", + "position": 1, + "columnName": "txid", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "PRIMARY", + "position": 1, + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "PRIMARY", + "position": 2, + "columnName": "snapshotFieldName", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "PRIMARY", + "position": 3, + "columnName": "snapshotCertificateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_lookup", + "position": 1, + "columnName": "snapshotFieldName", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_lookup", + "position": 2, + "columnName": "snapshotCertificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_lookup", + "position": 3, + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_parent", + "position": 1, + "columnName": "snapshotCertificateId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_parent", + "position": 2, + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_certificate_field_keys", + "name": "snapshot_certificate_parent", + "position": 3, + "columnName": "snapshotFieldName", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "PRIMARY", + "position": 1, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "PRIMARY", + "position": 2, + "columnName": "requestId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "PRIMARY", + "position": 3, + "columnName": "tableId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "PRIMARY", + "position": 4, + "columnName": "rowId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "snapshot_global_request", + "position": 1, + "columnName": "requestId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_edges", + "name": "snapshot_global_request", + "position": 2, + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_guards", + "name": "PRIMARY", + "position": 1, + "columnName": "proofId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "PRIMARY", + "position": 1, + "columnName": "tableId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "PRIMARY", + "position": 2, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "PRIMARY", + "position": 3, + "columnName": "rowId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_page", + "position": 1, + "columnName": "tableId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_page", + "position": 2, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_page", + "position": 3, + "columnName": "present", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_page", + "position": 4, + "columnName": "rowId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_target", + "position": 1, + "columnName": "tableId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_target", + "position": 2, + "columnName": "rowId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_global_keys", + "name": "snapshot_global_target", + "position": 3, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_profile_keys", + "name": "PRIMARY", + "position": 1, + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_profile_keys", + "name": "PRIMARY", + "position": 2, + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_profile_keys", + "name": "PRIMARY", + "position": 3, + "columnName": "snapshotRowId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "PRIMARY", + "position": 1, + "columnName": "snapshotTableId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "PRIMARY", + "position": 2, + "columnName": "snapshotUserId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "PRIMARY", + "position": 3, + "columnName": "snapshotLeftId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "PRIMARY", + "position": 4, + "columnName": "snapshotRightId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_map", + "position": 1, + "columnName": "snapshotTableId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_map", + "position": 2, + "columnName": "snapshotLeftId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_map", + "position": 3, + "columnName": "snapshotRightId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_map", + "position": 4, + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_right", + "position": 1, + "columnName": "snapshotTableId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_right", + "position": 2, + "columnName": "snapshotUserId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_right", + "position": 3, + "columnName": "snapshotRightId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "snapshot_relation_keys", + "name": "snapshot_relation_right", + "position": 4, + "columnName": "snapshotLeftId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "PRIMARY", + "position": 1, + "columnName": "syncStateId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "sync_states_refnum_index", + "position": 1, + "columnName": "refNum", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "sync_states_refnum_unique", + "position": 1, + "columnName": "refNum", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "sync_states_status_index", + "position": 1, + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "sync_states", + "name": "sync_states_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_expiry", + "position": 1, + "columnName": "noSendExpiryState", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_expiry", + "position": 2, + "columnName": "noSendExpiryDeadline", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_reclaim", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_nosend_reclaim", + "position": 2, + "columnName": "noSendExpiryReclaimTxid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_proven_tx", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_proven_tx", + "position": 2, + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_txid", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "idx_transactions_user_txid", + "position": 2, + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "PRIMARY", + "position": 1, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "transactions_proventxid_foreign", + "position": 1, + "columnName": "provenTxId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "transactions_reference_unique", + "position": 1, + "columnName": "reference", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "transactions_status_index", + "position": 1, + "columnName": "status", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "transactions", + "name": "transactions_txid_index", + "position": 1, + "columnName": "txid", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "YES", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels", + "name": "PRIMARY", + "position": 1, + "columnName": "txLabelId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels", + "name": "tx_labels_label_userid_unique", + "position": 1, + "columnName": "label", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels", + "name": "tx_labels_label_userid_unique", + "position": 2, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels", + "name": "tx_labels_userid_foreign", + "position": 1, + "columnName": "userId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "idx_tx_labels_map_tx_deleted", + "position": 1, + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "idx_tx_labels_map_tx_deleted", + "position": 2, + "columnName": "isDeleted", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_transactionid_index", + "position": 1, + "columnName": "transactionId", + "nonUnique": 1, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_transactionid_unique", + "position": 1, + "columnName": "txLabelId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_transactionid_unique", + "position": 2, + "columnName": "transactionId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "users", + "name": "PRIMARY", + "position": 1, + "columnName": "userId", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "tableName": "users", + "name": "users_identitykey_unique", + "position": 1, + "columnName": "identityKey", + "nonUnique": 0, + "direction": "A", + "prefix": null, + "nullable": "", + "type": "BTREE", + "visible": "YES", + "expression": null + } + ], + "foreignKeys": [ + { + "tableName": "certificate_fields", + "name": "certificate_fields_certificateid_foreign", + "position": 1, + "columnName": "certificateId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "certificates", + "foreignColumn": "certificateId" + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "certificates", + "name": "certificates_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_foreign", + "position": 1, + "columnName": "transactionId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "transactions", + "foreignColumn": "transactionId" + }, + { + "tableName": "commissions", + "name": "commissions_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "output_baskets", + "name": "output_baskets_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "output_tags", + "name": "output_tags_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputid_foreign", + "position": 1, + "columnName": "outputId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "outputs", + "foreignColumn": "outputId" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_foreign", + "position": 1, + "columnName": "outputTagId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "output_tags", + "foreignColumn": "outputTagId" + }, + { + "tableName": "outputs", + "name": "outputs_basketid_foreign", + "position": 1, + "columnName": "basketId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "output_baskets", + "foreignColumn": "basketId" + }, + { + "tableName": "outputs", + "name": "outputs_spentby_foreign", + "position": 1, + "columnName": "spentBy", + "foreignSchema": "ts569_snapshot", + "foreignTable": "transactions", + "foreignColumn": "transactionId" + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_foreign", + "position": 1, + "columnName": "transactionId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "transactions", + "foreignColumn": "transactionId" + }, + { + "tableName": "outputs", + "name": "outputs_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_proventxid_foreign", + "position": 1, + "columnName": "provenTxId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "proven_txs", + "foreignColumn": "provenTxId" + }, + { + "tableName": "sync_states", + "name": "sync_states_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "transactions", + "name": "transactions_proventxid_foreign", + "position": 1, + "columnName": "provenTxId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "proven_txs", + "foreignColumn": "provenTxId" + }, + { + "tableName": "transactions", + "name": "transactions_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "tx_labels", + "name": "tx_labels_userid_foreign", + "position": 1, + "columnName": "userId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "users", + "foreignColumn": "userId" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_transactionid_foreign", + "position": 1, + "columnName": "transactionId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "transactions", + "foreignColumn": "transactionId" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_foreign", + "position": 1, + "columnName": "txLabelId", + "foreignSchema": "ts569_snapshot", + "foreignTable": "tx_labels", + "foreignColumn": "txLabelId" + } + ], + "foreignRules": [ + { + "tableName": "certificate_fields", + "name": "certificate_fields_certificateid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "certificate_fields", + "name": "certificate_fields_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "certificates", + "name": "certificates_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "commissions", + "name": "commissions_transactionid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "commissions", + "name": "commissions_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "output_baskets", + "name": "output_baskets_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "output_tags", + "name": "output_tags_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "output_tags_map", + "name": "output_tags_map_outputtagid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "outputs", + "name": "outputs_basketid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "outputs", + "name": "outputs_spentby_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "outputs", + "name": "outputs_transactionid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "outputs", + "name": "outputs_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "proven_tx_reqs", + "name": "proven_tx_reqs_proventxid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "sync_states", + "name": "sync_states_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "transactions", + "name": "transactions_proventxid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "transactions", + "name": "transactions_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "tx_labels", + "name": "tx_labels_userid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_transactionid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + }, + { + "tableName": "tx_labels_map", + "name": "tx_labels_map_txlabelid_foreign", + "matchOption": "NONE", + "updateRule": "NO ACTION", + "deleteRule": "NO ACTION" + } + ], + "checks": [], + "triggers": [ + { + "tableName": "certificate_fields", + "name": "snapshot_certificate_field_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificate_fields", + "name": "snapshot_certificate_field_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificate_fields", + "name": "snapshot_certificate_field_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificate_fields", + "name": "snapshot_certificate_field_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_certificate_parent_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_certificate_parent_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_certificate_parent_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_certificate_parent_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_profile_2_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_profile_2_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "certificates", + "name": "snapshot_profile_2_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "commissions", + "name": "snapshot_profile_6_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "commissions", + "name": "snapshot_profile_6_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "commissions", + "name": "snapshot_profile_6_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.commissionId <=> NEW.commissionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_baskets", + "name": "snapshot_profile_4_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_baskets", + "name": "snapshot_profile_4_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_baskets", + "name": "snapshot_profile_4_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.basketId <=> NEW.basketId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_profile_5_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_profile_5_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_profile_5_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputTagId <=> NEW.outputTagId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_relation_1_left_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_relation_1_left_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_relation_1_left_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags", + "name": "snapshot_relation_1_left_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags_map", + "name": "snapshot_relation_1_map_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags_map", + "name": "snapshot_relation_1_map_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags_map", + "name": "snapshot_relation_1_map_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "output_tags_map", + "name": "snapshot_relation_1_map_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_profile_1_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_profile_1_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_profile_1_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_relation_1_right_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_relation_1_right_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_relation_1_right_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "outputs", + "name": "snapshot_relation_1_right_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_tx_reqs", + "name": "snapshot_global_req_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_tx_reqs", + "name": "snapshot_global_req_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_tx_reqs", + "name": "snapshot_global_req_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_tx_reqs", + "name": "snapshot_global_req_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_txs", + "name": "snapshot_global_proof_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_txs", + "name": "snapshot_global_proof_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_txs", + "name": "snapshot_global_proof_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "proven_txs", + "name": "snapshot_global_proof_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "snapshot_global_edges", + "name": "snapshot_global_edge_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_global_keys SET refs=refs-1 WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId; DELETE FROM snapshot_global_keys WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId AND refs=0; DELETE FROM snapshot_global_guards WHERE proofId=OLD.rowId AND OLD.tableId=1 AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.rowId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "snapshot_global_edges", + "name": "snapshot_global_edge_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_global_keys (tableId,userId,rowId,refs,present) VALUES (NEW.tableId,NEW.userId,NEW.rowId,1,CASE WHEN NEW.tableId=0 THEN 1 ELSE (SELECT present FROM snapshot_global_guards WHERE proofId=NEW.rowId FOR SHARE) END) ON DUPLICATE KEY UPDATE refs=snapshot_global_keys.refs+1; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "sync_states", + "name": "snapshot_profile_7_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "sync_states", + "name": "snapshot_profile_7_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "sync_states", + "name": "snapshot_profile_7_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.syncStateId <=> NEW.syncStateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_global_tx_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 3, + "definer": "root@%", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_global_tx_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_global_tx_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 3, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_global_tx_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 3, + "definer": "root@%", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_profile_0_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_profile_0_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_profile_0_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_relation_0_right_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_relation_0_right_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_relation_0_right_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "transactions", + "name": "snapshot_relation_0_right_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_profile_3_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_profile_3_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_profile_3_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txLabelId <=> NEW.txLabelId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_relation_0_left_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_relation_0_left_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_relation_0_left_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels", + "name": "snapshot_relation_0_left_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 2, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels_map", + "name": "snapshot_relation_0_map_after_update", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels_map", + "name": "snapshot_relation_0_map_before_update", + "event": "UPDATE", + "timing": "BEFORE", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels_map", + "name": "snapshot_relation_0_map_delete", + "event": "DELETE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "tx_labels_map", + "name": "snapshot_relation_0_map_insert", + "event": "INSERT", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + }, + { + "tableName": "users", + "name": "snapshot_sync_primary_change", + "event": "UPDATE", + "timing": "AFTER", + "actionOrder": 1, + "definer": "root@%", + "body": "BEGIN IF NOT (OLD.activeStorage <=> NEW.activeStorage) THEN\n INSERT INTO snapshot_sync_primary_epochs (userId, epoch) VALUES (NEW.userId, 1)\n ON DUPLICATE KEY UPDATE epoch = epoch + 1;\n END IF; END", + "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "charset": "utf8mb4", + "connectionCollation": "utf8mb4_unicode_ci", + "databaseCollation": "utf8mb4_0900_ai_ci" + } + ] +} diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs new file mode 100644 index 000000000..9250eed6d --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs @@ -0,0 +1,169 @@ +// Disposable, loopback-only synthetic MySQL qualification. Never targets an +// operator-supplied database, retains a volume, pulls an image, or builds one. +const { execFile } = require('node:child_process') +const { randomBytes, randomUUID } = require('node:crypto') +const { join } = require('node:path') +const assert = require('node:assert/strict') +const { executable, context, image, validateContext, validateContainer } = require('./snapshotArchiveDocker.cjs') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const journalFixtureGroups = Object.freeze(['generation', 'server-crash']) +const execute = (file, args, options) => + new Promise((resolve, reject) => { + execFile( + file, + args, + { encoding: 'utf8', killSignal: 'SIGKILL', maxBuffer: 1048576, ...options }, + (error, stdout) => { + if (error) reject(error) + else resolve(stdout) + } + ) + }) + +async function runFixture(group) { + assert(journalFixtureGroups.includes(group)) + const secret = randomBytes(32).toString('hex') + const fixtureEnvironment = { ...process.env, MYSQL_ROOT_PASSWORD: secret, MYSQL_PWD: secret } + const cancellation = new AbortController() + const interrupt = () => cancellation.abort(new Error('Snapshot fixture cancelled')) + process.once('SIGINT', interrupt) + process.once('SIGTERM', interrupt) + const command = async (args, signal) => + ( + await execute(executable, ['--context', context, ...args], { + timeout: 15000, + env: fixtureEnvironment, + ...(signal === undefined ? {} : { signal }) + }) + ).trim() + const docker = (...args) => command(args, cancellation.signal) + const cleanup = (...args) => command(args) + const owner = randomUUID() + const name = 'ts569-durable-' + owner + let id + let creating = false + let failure + try { + validateContext(JSON.parse(await docker('context', 'inspect', context))) + await docker('image', 'inspect', image) + creating = true + id = await docker( + 'run', + '--pull=never', + '--detach', + '--name', + name, + '--label', + 'network-ops.fixture=ts-stack-544-durable', + '--label', + 'network-ops.fixture-owner=' + owner, + '--memory', + '1g', + '--cpus', + '2', + '--pids-limit', + '256', + '--tmpfs', + '/var/lib/mysql:rw,nosuid,nodev,size=512m', + '--env', + 'MYSQL_ROOT_PASSWORD', + '--env', + 'MYSQL_DATABASE=ts569_snapshot', + '--publish', + '127.0.0.1::3306', + '--entrypoint', + '/bin/sh', + image, + '-c', + 'attempt=0; while [ "$attempt" -lt 32 ]; do attempt=$((attempt + 1)); /usr/local/bin/docker-entrypoint.sh mysqld --pid-file=/var/lib/mysql/fixture.pid & wait "$!"; done; exit 1' + ) + validateContainer(JSON.parse(await docker('inspect', id))[0], { name, owner, id }) + const deadline = Date.now() + 60000 + let ready = false + function* pendingReadiness() { + while (!ready) yield undefined + } + await runInSeries(pendingReadiness(), async () => { + try { + // TCP specifically excludes the entrypoint's temporary socket-only server. + await docker( + 'exec', + '--env', + 'MYSQL_PWD', + id, + 'mysqladmin', + '--protocol=tcp', + '--host=127.0.0.1', + '--user=root', + 'ping' + ) + ready = true + } catch (error) { + if (cancellation.signal.aborted || Date.now() >= deadline) throw error + await new Promise(resolve => setTimeout(resolve, 500)) + } + }) + { + const started = Date.now() + process.stdout.write(JSON.stringify({ group, status: 'started' }) + '\n') + const result = await execute( + process.execPath, + [ + join(__dirname, group === 'generation' ? 'snapshotJournalMysql.cjs' : 'snapshotJournalMysqlServerCrash.cjs'), + group + ], + { + env: { + ...process.env, + TS_STACK_SNAPSHOT_CONTAINER: name, + TS_STACK_SNAPSHOT_CONTAINER_ID: id, + TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, + TS_STACK_SNAPSHOT_MYSQL_SECRET: secret + }, + signal: cancellation.signal, + timeout: group === 'generation' ? 180000 : 240000 + } + ) + process.stdout.write(result) + process.stdout.write(JSON.stringify({ group, status: 'passed', milliseconds: Date.now() - started }) + '\n') + } + } catch (error) { + failure = error + } + let cleanupFailure + if (creating) { + // A timed-out create may have succeeded before its reply was lost. Cleanup + // keeps its own deadline after cancellation and requires the complete claim. + try { + const owned = () => cleanup('ps', '-aq', '--filter', 'label=network-ops.fixture-owner=' + owner) + const pending = await owned() + if (pending !== '') { + const containers = JSON.parse(await cleanup('inspect', ...pending.split('\n'))) + assert.equal(containers.length, 1) + validateContainer(containers[0], { name, owner, id }) + await cleanup('rm', '--force', containers[0].Id) + } + assert.equal(await owned(), '') + } catch (error) { + cleanupFailure = error + } + } + process.removeListener('SIGINT', interrupt) + process.removeListener('SIGTERM', interrupt) + if (cleanupFailure !== undefined) + throw new AggregateError( + failure === undefined ? [cleanupFailure] : [failure, cleanupFailure], + 'Snapshot fixture cleanup is unproved' + ) + if (failure !== undefined) throw failure + cancellation.signal.throwIfAborted() +} +async function main() { + await runInSeries(journalFixtureGroups, runFixture) +} +module.exports = main +if (require.main === module) + main().catch(error => { + console.error(error) + process.exitCode = 1 + }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs new file mode 100644 index 000000000..ec1fd6f57 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs @@ -0,0 +1,263 @@ +const assert = require('node:assert/strict'), + { fork } = require('node:child_process'), + { mkdtemp, rm } = require('node:fs/promises'), + { tmpdir } = require('node:os'), + { join } = require('node:path'), + { writeFileSync, readFileSync } = require('node:fs') +const { + open, + seedArchiveClosure, + StorageKnex, + StorageProvider, + exact, + tables +} = require('./snapshotJournalMysqlConnection.cjs') +const { + installSnapshotJournalMysqlGeneration: install, + readSnapshotJournalMysqlGeneration: read, + completeSnapshotJournalMysqlGeneration: complete +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.js') +const { + copySnapshotJournalBootstrapPage: copy +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalBootstrap.js') +const ceiling = '9223372036854775807', + intent = 'snapshot_journal_generation' +async function isolate(k, isolation) { + assert(['READ COMMITTED', 'REPEATABLE READ'].includes(isolation)) + await k.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + const [[level]] = await k.raw('SELECT @@transaction_isolation isolation') + assert.equal(level.isolation.replaceAll('-', ' '), isolation) + await k.raw('SET SESSION innodb_lock_wait_timeout=5') +} +async function finish(k) { + for (let i = 0; i < 100; i++) { + const page = await copy(k) + assert(!page.invalidated) + if (page.complete) return await complete(k, ceiling) + assert(i < 99) + } +} +async function clear(k) { + const [triggers] = await k.raw( + "SELECT TRIGGER_NAME name FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND LEFT(TRIGGER_NAME,17)='snapshot_journal_'" + ) + for (const row of triggers) await k.raw('DROP TRIGGER ??', [row.name]) + const [names] = await k.raw( + "SELECT TABLE_NAME name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,17)='snapshot_journal_'" + ) + for (const row of names) await k.schema.dropTable(row.name) +} +async function rows(k) { + const result = {} + for (const name of tables) result[name] = (await k(name).select('*')).map(row => JSON.stringify(row)).sort() + return result +} +async function child() { + process.once('disconnect', () => process.exit(1)) + const childDeadline = setTimeout(() => process.exit(1), 20000) + childDeadline.unref() + const k = open(), + boundary = process.argv[3], + marker = process.argv[4], + isolation = process.argv[5] + let completing = false + const park = phase => { + if (boundary === phase) { + writeFileSync(marker, phase) + process.kill(process.pid, 'SIGKILL') + } + } + const object = sql => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] + k.on('query', q => { + const name = object(q.sql) + if (name) park('before-' + name) + if (completing && q.sql === 'COMMIT;') park('complete-before-commit') + }) + k.on('query-response', (_r, q) => { + const name = object(q.sql) + if (name) park('after-' + name) + if (q.sql.startsWith('update `snapshot_journal_generation` set `nextObject`')) park('after-ack-' + q.bindings[0]) + if (completing && q.sql === 'COMMIT;') park('complete-after-commit') + }) + try { + await isolate(k, isolation) + await install(k, ceiling) + if (boundary.startsWith('complete-')) { + for (let i = 0; i < 100; i++) { + const page = await copy(k) + if (page.complete) break + assert(i < 99) + } + completing = true + await complete(k, ceiling) + park('complete-after-commit') + } + throw new Error('Crash boundary missed: ' + boundary) + } finally { + await k.destroy() + } +} +async function killAt(boundary, marker, isolation) { + const p = fork(__filename, ['child', boundary, marker, isolation], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + let stderr = '' + p.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-6000) + }) + const timer = setTimeout(() => p.kill('SIGKILL'), 20000), + result = await new Promise((resolve, reject) => { + p.once('error', reject) + p.once('exit', (code, signal) => resolve({ code, signal })) + }) + clearTimeout(timer) + assert.equal(result.signal, 'SIGKILL', stderr) + assert.equal(readFileSync(marker, 'utf8'), boundary) +} +async function main() { + const k = open(), + source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }), + directory = await mkdtemp(join(tmpdir(), 'ts569-mysql-generation-')), + results = [] + try { + await source.migrate('journal generation fixture', 'synthetic-journal-generation') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)), + { user: foreign } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, foreign.userId) + const baseline = await rows(k) + for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) { + await isolate(k, isolation) + await clear(k) + const created = await install(k, ceiling) + assert.equal(created.nextObject, 57) + assert.equal(created.complete, false) + assert.equal(created.enabled, true) + assert.deepEqual(await install(k, ceiling), created) + assert.deepEqual(await read(k, ceiling), created) + await assert.rejects(complete(k, ceiling), /Invalid or unowned/) + const completed = await finish(k) + await exact(k) + assert.equal(completed.complete, true) + assert.equal(completed.epoch, created.epoch) + assert.deepEqual(await read(k, ceiling), completed) + assert.deepEqual(await complete(k, ceiling), completed) + await k.transaction(async t => assert.deepEqual(await read(t, ceiling), completed)) + for (const [_name, up, down] of [ + [ + 'owner-comment', + "ALTER TABLE snapshot_journal_clock COMMENT='foreign'", + "ALTER TABLE snapshot_journal_clock COMMENT='snapshot-journal-owner:" + created.epoch + "'" + ], + [ + 'index', + 'CREATE INDEX foreign_generation_index ON snapshot_journal_physical(present)', + 'DROP INDEX foreign_generation_index ON snapshot_journal_physical' + ], + [ + 'check', + 'ALTER TABLE snapshot_journal_clock ALTER CHECK snapshot_journal_clock_chk_1 NOT ENFORCED', + 'ALTER TABLE snapshot_journal_clock ALTER CHECK snapshot_journal_clock_chk_1 ENFORCED' + ], + [ + 'foreign-observer', + 'CREATE TRIGGER foreign_generation_observer AFTER UPDATE ON snapshot_journal_clock FOR EACH ROW BEGIN DO 0; END', + 'DROP TRIGGER foreign_generation_observer' + ], + [ + 'source-binding', + "ALTER TABLE tx_labels ALTER label SET DEFAULT 'changed'", + 'ALTER TABLE tx_labels ALTER label DROP DEFAULT' + ] + ]) { + await k.raw(up) + await assert.rejects(read(k, ceiling), /Invalid or unowned/) + await assert.rejects(install(k, ceiling), /Invalid or unowned/) + await k.raw(down) + assert.deepEqual(await read(k, ceiling), completed) + } + await k(intent).update({ nextObject: 56, complete: 0 }) + await k.raw('CREATE TABLE snapshot_journal_foreign(id INT)') + await assert.rejects(install(k, ceiling), /Invalid or unowned/) + assert(await k.schema.hasTable('snapshot_journal_foreign')) + await k.schema.dropTable('snapshot_journal_foreign') + await k(intent).update({ nextObject: 57, complete: 1 }) + await k('snapshot_journal_clock').delete() + await assert.rejects(install(k, ceiling), /Invalid or unowned/) + await k('snapshot_journal_clock').insert({ id: 1, ceiling }) + await k('snapshot_journal_bootstrap').update({ stream: 16, cursor: null }) + await assert.rejects(read(k, ceiling), /Invalid or unowned/) + await k('snapshot_journal_bootstrap').update({ stream: 17, cursor: null }) + await exact(k) + assert.deepEqual(await rows(k), baseline) + await clear(k) + const boundaries = [ + 'before-snapshot_journal_generation', + 'after-snapshot_journal_generation', + 'before-snapshot_journal_clock', + 'after-snapshot_journal_clock', + 'after-snapshot_journal_bootstrap', + 'after-snapshot_journal_scope_0_INSERT', + 'after-ack-7', + 'after-snapshot_journal_physical_12_DELETE', + 'after-ack-57', + 'complete-before-commit', + 'complete-after-commit' + ] + for (const boundary of boundaries) { + await killAt(boundary, join(directory, isolation.replaceAll(' ', '-') + '-' + boundary), isolation) + const saved = (await k.schema.hasTable(intent)) ? await k(intent).first() : undefined + if (boundary.startsWith('complete-')) assert.equal(saved.complete, boundary === 'complete-after-commit' ? 1 : 0) + const resumed = await install(k, ceiling) + if (saved) assert.equal(resumed.epoch, saved.epoch) + await finish(k) + await exact(k) + assert.deepEqual(await rows(k), baseline) + await k('tx_labels').where('txLabelId', 1).update({ label: 'post-recovery writer' }) + await k('tx_labels') + .where('txLabelId', 1) + .update({ + label: JSON.parse(baseline.tx_labels.find(row => JSON.parse(row).txLabelId === 1)).label + }) + await exact(k) + assert.deepEqual(await rows(k), baseline) + await clear(k) + console.log(JSON.stringify({ isolation, boundary, status: 'passed' })) + } + results.push({ + isolation, + installedObjects: 58, + exactResumeAndSourcePreserved: true, + bootstrapAndObservers: true, + refusals: true, + clientSigkillBoundaries: boundaries.length, + completionAtomic: true, + writerBarrierReleased: true + }) + } + console.log( + JSON.stringify({ + status: 'MySQL journal generation', + results, + readerAdvertised: false, + limitations: [ + 'client SIGKILL only; server-crash/replication/failover remains open', + 'registered forward migration,quota,receipt and receiver remain incomplete' + ] + }) + ) + } finally { + await source.destroy() + await rm(directory, { recursive: true, force: true }) + } +} +if (process.argv[2] === 'child') + child().catch(e => { + console.error(e) + process.exitCode = 1 + }) +else + main().catch(e => { + console.error(e) + process.exitCode = 1 + }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlConnection.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlConnection.cjs new file mode 100644 index 000000000..7f3927b6f --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlConnection.cjs @@ -0,0 +1,25 @@ +const { execFileSync } = require('node:child_process') +const { knex } = require('knex') +const { executable, context, validateContext, validateContainer } = require('./snapshotArchiveDocker.cjs') +const container = process.env.TS_STACK_SNAPSHOT_CONTAINER +const expectedId = process.env.TS_STACK_SNAPSHOT_CONTAINER_ID +const owner = process.env.TS_STACK_SNAPSHOT_CONTAINER_OWNER +const secret = process.env.TS_STACK_SNAPSHOT_MYSQL_SECRET +if (!container || !expectedId || !owner || !secret) throw new Error('Use the bounded fixture launcher') +const docker = (...args) => + execFileSync(executable, ['--context', context, ...args], { encoding: 'utf8', timeout: 15000 }) +validateContext(JSON.parse(docker('context', 'inspect', context))) +const actual = JSON.parse(docker('inspect', container))[0] +validateContainer(actual, { name: container, owner, id: expectedId }) +const port = Number(docker('port', expectedId, '3306/tcp').trim().split(':').at(-1)) +const connection = { + host: '127.0.0.1', + port, + user: 'root', + password: secret, + database: 'ts569_snapshot', + timezone: 'Z' +} + +const open = () => knex({ client: 'mysql2', connection, pool: { min: 1, max: 1 }, acquireConnectionTimeout: 5000 }) +module.exports = { open, ...require('./snapshotJournalNativeFixture.cjs') } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs new file mode 100644 index 000000000..d769859aa --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs @@ -0,0 +1,270 @@ +const assert = require('node:assert/strict'), + { fork } = require('node:child_process'), + { mkdtemp, rm } = require('node:fs/promises'), + { tmpdir } = require('node:os'), + { join } = require('node:path'), + { writeFileSync, readFileSync } = require('node:fs') +const { + open, + seedArchiveClosure, + StorageKnex, + StorageProvider, + exact, + tables +} = require('./snapshotJournalMysqlConnection.cjs') +const { + installSnapshotJournalMysqlGeneration: install, + completeSnapshotJournalMysqlGeneration: complete +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.js') +const { + copySnapshotJournalBootstrapPage: copy +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalBootstrap.js') +const ceiling = '9223372036854775807', + intent = 'snapshot_journal_generation' +const { execFileSync } = require('node:child_process') +const { executable, context, validateContainer } = require('./snapshotArchiveDocker.cjs') +const containerId = process.env.TS_STACK_SNAPSHOT_CONTAINER_ID +const docker = (...args) => + execFileSync(executable, ['--context', context, ...args], { + encoding: 'utf8', + timeout: 5000, + env: { ...process.env, MYSQL_PWD: process.env.TS_STACK_SNAPSHOT_MYSQL_SECRET }, + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +function ownedServer() { + const actual = JSON.parse(docker('inspect', containerId))[0] + validateContainer(actual, { + id: containerId, + name: process.env.TS_STACK_SNAPSHOT_CONTAINER, + owner: process.env.TS_STACK_SNAPSHOT_CONTAINER_OWNER + }) + assert.deepEqual(actual.Config.Entrypoint, ['/bin/sh']) + assert.equal(actual.HostConfig.Memory, 1073741824) + assert.equal(actual.HostConfig.NanoCpus, 2000000000) + assert.equal(actual.HostConfig.Binds, null) + assert.equal(actual.HostConfig.Tmpfs['/var/lib/mysql'], 'rw,nosuid,nodev,size=512m') + const pid = docker('exec', containerId, 'cat', '/var/lib/mysql/fixture.pid') + assert(/^[0-9]+$/.test(pid) && Number(pid) > 1) + assert.equal(docker('exec', containerId, 'cat', '/proc/' + pid + '/comm'), 'mysqld') + return pid +} +function crashServer() { + const pid = ownedServer() + docker('exec', containerId, '/bin/sh', '-c', 'kill -KILL "$1"', 'journal-fixture', pid) + return pid +} +async function ready(previous) { + const deadline = Date.now() + 20000 + let last + while (Date.now() < deadline) { + try { + docker( + 'exec', + '--env', + 'MYSQL_PWD', + containerId, + 'mysqladmin', + '--protocol=tcp', + '--host=127.0.0.1', + '--user=root', + 'ping' + ) + const pid = ownedServer() + assert.notEqual(pid, previous) + return pid + } catch (error) { + last = error + await new Promise(resolve => setTimeout(resolve, 300)) + } + } + throw last +} + +async function isolate(k, isolation) { + assert(['READ COMMITTED', 'REPEATABLE READ'].includes(isolation)) + await k.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + const [[level]] = await k.raw('SELECT @@transaction_isolation isolation') + assert.equal(level.isolation.replaceAll('-', ' '), isolation) + await k.raw('SET SESSION innodb_lock_wait_timeout=5') +} +async function finish(k) { + for (let i = 0; i < 100; i++) { + const page = await copy(k) + assert(!page.invalidated) + if (page.complete) return await complete(k, ceiling) + assert(i < 99) + } +} +async function clear(k) { + const [triggers] = await k.raw( + "SELECT TRIGGER_NAME name FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND LEFT(TRIGGER_NAME,17)='snapshot_journal_'" + ) + for (const row of triggers) await k.raw('DROP TRIGGER ??', [row.name]) + const [names] = await k.raw( + "SELECT TABLE_NAME name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,17)='snapshot_journal_'" + ) + for (const row of names) await k.schema.dropTable(row.name) +} +async function rows(k) { + const result = {} + for (const name of tables) result[name] = (await k(name).select('*')).map(row => JSON.stringify(row)).sort() + return result +} +async function child() { + process.once('disconnect', () => process.exit(1)) + const childDeadline = setTimeout(() => process.exit(1), 20000) + childDeadline.unref() + const k = open(), + boundary = process.argv[3], + marker = process.argv[4], + isolation = process.argv[5] + let completing = false + const park = phase => { + if (boundary === phase) { + const serverPid = crashServer() + writeFileSync(marker, JSON.stringify({ phase, serverPid })) + process.kill(process.pid, 'SIGKILL') + } + } + const object = sql => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] + k.on('query', q => { + const name = object(q.sql) + if (name) park('before-' + name) + if (completing && q.sql === 'COMMIT;') park('complete-before-commit') + }) + k.on('query-response', (_r, q) => { + const name = object(q.sql) + if (name) park('after-' + name) + if (q.sql.startsWith('update `snapshot_journal_generation` set `nextObject`')) park('after-ack-' + q.bindings[0]) + if (completing && q.sql === 'COMMIT;') park('complete-after-commit') + }) + try { + await isolate(k, isolation) + await install(k, ceiling) + if (boundary.startsWith('complete-')) { + for (let i = 0; i < 100; i++) { + const page = await copy(k) + if (page.complete) break + assert(i < 99) + } + completing = true + await complete(k, ceiling) + park('complete-after-commit') + } + throw new Error('Crash boundary missed: ' + boundary) + } finally { + await k.destroy() + } +} +async function killAt(boundary, marker, isolation) { + const p = fork(__filename, ['child', boundary, marker, isolation], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + let stderr = '' + p.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-6000) + }) + const timer = setTimeout(() => p.kill('SIGKILL'), 20000), + result = await new Promise((resolve, reject) => { + p.once('error', reject) + p.once('exit', (code, signal) => resolve({ code, signal })) + }) + clearTimeout(timer) + assert.equal(result.signal, 'SIGKILL', stderr) + const evidence = JSON.parse(readFileSync(marker, 'utf8')) + assert.equal(evidence.phase, boundary) + return { previousServerPid: evidence.serverPid, newServerPid: await ready(evidence.serverPid) } +} +async function main() { + let k = open(), + source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + const directory = await mkdtemp(join(tmpdir(), 'ts569-mysql-server-crash-')), + results = [] + try { + ownedServer() + await source.migrate('journal server crash fixture', 'synthetic-journal-server-crash') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)), + { user: foreign } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, foreign.userId) + const baseline = await rows(k) + await source.destroy() + source = undefined + k = undefined + const boundaries = [ + 'after-snapshot_journal_generation', + 'after-snapshot_journal_clock', + 'after-snapshot_journal_scope_0_INSERT', + 'after-snapshot_journal_physical_12_DELETE', + 'complete-before-commit', + 'complete-after-commit' + ] + for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) { + for (const boundary of boundaries) { + const restarted = await killAt( + boundary, + join(directory, isolation.replaceAll(' ', '-') + '-' + boundary), + isolation + ) + k = open() + await isolate(k, isolation) + const saved = await k(intent).first() + assert(saved) + if (boundary.startsWith('complete-')) assert.equal(saved.complete, boundary === 'complete-after-commit' ? 1 : 0) + const resumed = await install(k, ceiling) + assert.equal(resumed.epoch, saved.epoch) + await finish(k) + await exact(k) + assert.deepEqual(await rows(k), baseline) + await k('tx_labels').where('txLabelId', 1).update({ label: 'post-server-recovery writer' }) + await exact(k) + await k('tx_labels') + .where('txLabelId', 1) + .update({ + label: JSON.parse(baseline.tx_labels.find(row => JSON.parse(row).txLabelId === 1)).label + }) + await exact(k) + assert.deepEqual(await rows(k), baseline) + await clear(k) + await k.destroy() + k = undefined + results.push({ + isolation, + boundary, + ...restarted, + sourcePreserved: true, + epochPreserved: true, + bootstrapExact: true, + writerRecovered: true + }) + console.log(JSON.stringify(results.at(-1))) + } + } + console.log( + JSON.stringify({ + status: 'isolated MySQL server-process crash recovery', + serverCrashes: results.length, + results, + readerAdvertised: false, + limitations: [ + 'tmpfs-backed single MySQL8.4 server; no machine power-loss,replication,PXC/failover or production performance acceptance', + 'registered migration,quota,receipt and receiver remain incomplete' + ] + }) + ) + } finally { + if (source) await source.destroy() + else if (k) await k.destroy() + await rm(directory, { recursive: true, force: true }) + } +} +if (process.argv[2] === 'child') + child().catch(e => { + console.error(e) + process.exitCode = 1 + }) +else + main().catch(e => { + console.error(e) + process.exitCode = 1 + }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalNativeFixture.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalNativeFixture.cjs new file mode 100644 index 000000000..1cb496810 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalNativeFixture.cjs @@ -0,0 +1,229 @@ +// Synthetic native fixtures and an independent source-table ownership oracle. +// This fixture does not use production journal generators to compute expected rows. +const assert = require('node:assert/strict') +const { knex } = require('knex') +const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') +const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const identity = '02' + '11'.repeat(32) + +async function seedArchiveClosure(source, userId, otherId) { + const instant = '2026-01-01T00:00:00.000Z' + const date = source.knex.client.config.client === 'mysql2' ? new Date(instant) : instant + const timestamp = { created_at: date, updated_at: date } + const k = source.knex + await k('output_baskets').del() + await runInSeries([1, 2, 3], async id => { + const profileId = id === 2 ? otherId : userId + await k('proven_txs').insert({ + ...timestamp, + provenTxId: id, + txid: String(id).repeat(64), + height: id, + index: 0, + merklePath: Buffer.from([id, 0, 255]), + rawTx: Buffer.from([id, 1, 255]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + await k('transactions').insert({ + ...timestamp, + transactionId: id, + userId: profileId, + provenTxId: id === 3 ? null : id, + status: 'completed', + reference: `tx-${id}`, + isOutgoing: true, + satoshis: 0, + description: `tx-${id}`, + txid: String(id).repeat(64), + rawTx: Buffer.from([id, 2, 255]), + inputBEEF: Buffer.from([id, 3, 255]) + }) + await k('proven_tx_reqs').insert({ + ...timestamp, + provenTxReqId: id, + provenTxId: id, + txid: String(id).repeat(64), + status: 'completed', + attempts: 0, + notified: true, + history: '{}', + notify: '{}', + rawTx: Buffer.from([id, 4, 255]), + wasBroadcast: true + }) + await k('output_baskets').insert({ + ...timestamp, + basketId: id, + userId: profileId, + name: `basket-${id}`, + isDeleted: id === 3 + }) + await k('outputs').insert({ + ...timestamp, + outputId: id, + userId: profileId, + transactionId: id, + basketId: id, + spendable: false, + change: true, + vout: 0, + satoshis: 1, + providedBy: 'you', + purpose: '', + type: 'P2PKH', + lockingScript: Buffer.from([id, 5, 255]) + }) + await k('commissions').insert({ + ...timestamp, + commissionId: id, + userId: profileId, + transactionId: id, + satoshis: 0, + keyOffset: 'offset', + isRedeemed: true, + lockingScript: Buffer.from([id, 6, 255]) + }) + await k('output_tags').insert({ + ...timestamp, + outputTagId: id, + userId: profileId, + tag: `tag-${id}`, + isDeleted: id === 3 + }) + await k('output_tags_map').insert({ + ...timestamp, + outputTagId: id, + outputId: id, + isDeleted: id === 3 + }) + await k('tx_labels').insert({ + ...timestamp, + txLabelId: id, + userId: profileId, + label: `label-${id}`, + isDeleted: id === 3 + }) + await k('tx_labels_map').insert({ + ...timestamp, + txLabelId: id, + transactionId: id, + isDeleted: id === 3 + }) + await k('certificates').insert({ + ...timestamp, + certificateId: id, + userId: profileId, + serialNumber: `serial-${id}`, + type: 'type', + certifier: identity, + subject: identity, + revocationOutpoint: 'a'.repeat(64) + '.0', + signature: 'signature', + isDeleted: id === 3 + }) + await runInSeries(['a', 'Z', 'é', '😀'], async fieldName => { + await k('certificate_fields').insert({ + ...timestamp, + certificateId: id, + userId: profileId, + fieldName, + fieldValue: `value-${id}`, + masterKey: 'key' + }) + }) + await k('sync_states').insert({ + ...timestamp, + syncStateId: id, + userId: profileId, + storageIdentityKey: `peer-${id}`, + storageName: `peer-${id}`, + status: 'unknown', + init: true, + refNum: `state-${id}`, + syncMap: '{}', + when: date + }) + }) + // Composite positions must handle repeated first keys and preserve deleted mappings. + await k('output_tags_map').insert({ ...timestamp, outputTagId: 1, outputId: 3, isDeleted: true }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: 1, transactionId: 3, isDeleted: true }) +} + +const profiles = [ + ['transactions', 'transactionId'], + ['outputs', 'outputId'], + ['certificates', 'certificateId'], + ['tx_labels', 'txLabelId'], + ['output_baskets', 'basketId'], + ['output_tags', 'outputTagId'], + ['commissions', 'commissionId'], + ['sync_states', 'syncStateId'] +] +const numeric = [...profiles, ['proven_txs', 'provenTxId'], ['proven_tx_reqs', 'provenTxReqId']] +const tables = [...numeric.map(([table]) => table), 'tx_labels_map', 'output_tags_map', 'certificate_fields'] +const quote = value => '`' + value.replaceAll('`', '``') + '`' +const binaryType = k => (k.client.config.client === 'mysql2' ? 'BINARY' : 'BLOB') +const empty = k => "CAST('' AS " + binaryType(k) + ')' +const rawRows = async (k, sql) => (k.client.config.client === 'mysql2' ? (await k.raw(sql))[0] : await k.raw(sql)) +const tuple = (k, table, p) => { + const key = numeric.find(([name]) => name === table)?.[1] + if (key) return [p + '.' + quote(key), '0', empty(k)] + if (table === 'tx_labels_map') return [p + '.txLabelId', p + '.transactionId', empty(k)] + if (table === 'output_tags_map') return [p + '.outputTagId', p + '.outputId', empty(k)] + return [p + '.certificateId', '0', 'CAST(' + p + '.fieldName AS ' + binaryType(k) + ')'] +} +const normalized = (rows, scope) => + rows + .map(row => + JSON.stringify([ + Number(row.tableId), + ...(scope ? [Number(row.userId)] : []), + Number(row.id1), + Number(row.id2), + Buffer.from(row.exactText).toString('hex') + ]) + ) + .sort() +async function expected(k) { + const selections = profiles.map( + ([table, key], id) => + `SELECT ${id} tableId,userId,${quote(key)} id1,0 id2,${empty(k)} exactText FROM ${quote(table)}` + ) + for (const [id, table, left, leftKey, right, rightKey] of [ + [10, 'tx_labels_map', 'tx_labels', 'txLabelId', 'transactions', 'transactionId'], + [11, 'output_tags_map', 'output_tags', 'outputTagId', 'outputs', 'outputId'] + ]) { + for (const [parent, key] of [ + [left, leftKey], + [right, rightKey] + ]) + selections.push( + `SELECT ${id},p.userId,m.${leftKey},m.${rightKey},${empty(k)} FROM ${table} m JOIN ${parent} p ON m.${key}=p.${key}` + ) + } + selections.push( + `SELECT 12,userId,certificateId,0,CAST(fieldName AS ${binaryType(k)}) FROM certificate_fields`, + `SELECT 12,c.userId,f.certificateId,0,CAST(f.fieldName AS ${binaryType(k)}) FROM certificate_fields f JOIN certificates c ON c.certificateId=f.certificateId`, + `SELECT 9,t.userId,r.provenTxReqId,0,${empty(k)} FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid`, + `SELECT 8,t.userId,p.provenTxId,0,${empty(k)} FROM transactions t JOIN proven_txs p ON p.provenTxId=t.provenTxId`, + `SELECT 8,t.userId,p.provenTxId,0,${empty(k)} FROM transactions t JOIN proven_tx_reqs r ON r.txid=t.txid JOIN proven_txs p ON p.provenTxId=r.provenTxId` + ) + return await rawRows(k, selections.join(' UNION ')) +} +async function exact(k) { + assert.deepEqual( + normalized(await k('snapshot_journal_scope').where('present', 1), true), + normalized(await expected(k), true) + ) + const physical = await k('snapshot_journal_physical').where('present', 1) + const selections = tables.map((table, tableId) => { + const keys = tuple(k, table, 's') + return `SELECT ${tableId} tableId,${keys[0]} id1,${keys[1]} id2,${keys[2]} exactText FROM ${quote(table)} s` + }) + assert.deepEqual(normalized(physical, false), normalized(await rawRows(k, selections.join(' UNION ALL ')), false)) + for (const row of physical) assert(BigInt(String(row.generation)) > 0n) + if (k.client.config.client === 'mysql2') assert.equal((await k('snapshot_journal_events')).length, 0) +} +module.exports = { knex, StorageKnex, StorageProvider, seedArchiveClosure, tables, exact } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs new file mode 100644 index 000000000..a152299a3 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs @@ -0,0 +1,166 @@ +const assert = require('node:assert/strict'), + { fork } = require('node:child_process'), + { mkdtemp, rm } = require('node:fs/promises'), + { readFileSync, writeFileSync } = require('node:fs'), + { tmpdir } = require('node:os'), + { join } = require('node:path') +const { knex, StorageKnex, StorageProvider, tables, exact } = require('./snapshotJournalNativeFixture.cjs'), + { seedArchiveClosure } = require('./snapshotJournalNativeFixture.cjs') +const { + installSnapshotJournalSqliteGeneration: install, + readSnapshotJournalSqliteGeneration: read, + completeSnapshotJournalSqliteGeneration: complete +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.js') +const { + copySnapshotJournalBootstrapPage: copy +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalBootstrap.js') +const open = filename => + knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) +const finish = async k => { + for (let page = 0; page < 100; page++) if ((await copy(k)).complete) return + throw new Error('Bootstrap incomplete') +} +async function child() { + process.once('disconnect', () => process.exit(1)) + const childDeadline = setTimeout(() => process.exit(1), 20000) + childDeadline.unref() + + const filename = process.argv[3], + boundary = process.argv[4], + k = open(filename) + const park = phase => { + if (phase === boundary) { + writeFileSync(filename + '.marker', phase) + process.kill(process.pid, 'SIGKILL') + } + } + k.on('query-response', (_value, q) => { + const sql = q.sql.toLowerCase() + if (sql.startsWith('create trigger snapshot_journal_physical_12_delete')) park('install-after-ddl') + if (sql.startsWith('insert into `snapshot_journal_generation`')) park('install-after-generation') + if (sql.startsWith('insert into `snapshot_journal_bootstrap`')) park('install-after-bootstrap') + if (sql.startsWith('update `snapshot_journal_generation`')) park('complete-after-state') + }) + k.on('query', q => { + if (q.sql.toLowerCase().startsWith('update `snapshot_journal_generation`')) park('complete-before-state') + }) + try { + if (boundary.startsWith('install-')) { + await install(k, '1000000') + park('install-after-commit') + } else { + await complete(k) + park('complete-after-commit') + } + throw new Error('Boundary not reached') + } finally { + await k.destroy() + } +} +async function killAt(filename, boundary) { + const processChild = fork(__filename, ['child', filename, boundary], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + let stderr = '' + processChild.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-6000) + }) + const timeout = setTimeout(() => processChild.kill('SIGKILL'), 15000) + const result = await new Promise((resolve, reject) => { + processChild.once('error', reject) + processChild.once('exit', (code, signal) => resolve({ code, signal })) + }) + clearTimeout(timeout) + assert.equal(result.signal, 'SIGKILL', stderr) + assert.equal(readFileSync(filename + '.marker', 'utf8'), boundary) + return result +} +async function main() { + const directory = await mkdtemp(join(tmpdir(), 'ts569-journal-generation-kill-')), + results = [] + try { + for (const boundary of [ + 'install-after-ddl', + 'install-after-generation', + 'install-after-bootstrap', + 'install-after-commit', + 'complete-before-state', + 'complete-after-state', + 'complete-after-commit' + ]) { + const filename = join(directory, boundary + '.sqlite'), + k = open(filename), + source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + try { + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('journal generation process-loss fixture', 'synthetic-source') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)), + { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, other.userId) + const original = {} + for (const table of tables) original[table] = await k(table) + if (boundary.startsWith('complete-')) { + await install(k, '1000000') + await finish(k) + } + const killed = await killAt(filename, boundary) + await k.transaction(async t => { + await t('snapshot_index_generation_v2') + .where('id', 0) + .update({ complete: t.ref('complete') }) + }) + const objects = await k('sqlite_master').whereRaw('lower(substr(name,1,17))=?', ['snapshot_journal_']) + const committed = boundary.endsWith('after-commit') + if (boundary.startsWith('install-')) assert.equal(objects.length, committed ? 58 : 0) + else assert.equal((await read(k)).complete, committed) + await install(k, '1000000') + await finish(k) + await complete(k) + assert.equal((await read(k)).complete, true) + await exact(k) + for (const table of tables) assert.deepEqual(await k(table), original[table]) + assert.deepEqual(await k.raw('PRAGMA foreign_key_check'), []) + results.push({ + boundary, + signal: killed.signal, + atomicity: committed ? 'committed' : 'rolled back', + writerLockReleased: true, + sourcePreserved: true, + resumedComplete: true + }) + } finally { + await source.destroy() + } + } + console.log( + JSON.stringify({ + status: 'SQLite journal generation native WAL process-loss checks', + results, + readerAdvertised: false, + limitations: [ + 'not yet a registered forward migration', + 'no MySQL implicit-DDL lifecycle qualification', + 'no receipt/quota/receiver or platform-scale acceptance' + ] + }) + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } +} +if (process.argv[2] === 'child') + child().catch(error => { + console.error(error) + process.exitCode = 1 + }) +else + main().catch(error => { + console.error(error) + process.exitCode = 1 + }) diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index f2fcf83b6..32a9a8fd2 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -29,7 +29,7 @@ function workflowJobBlocks(workflow) { function assertWalletMutationTimeout(job, defaultMinutes) { const targets = - '["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]' + '["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-journal","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]' const expected = ` timeout-minutes: \${{ contains(fromJSON('${targets}'), matrix.target) && 90 || ${defaultMinutes} }}` assert.equal(job.source.match(/^ timeout-minutes: .+$/m)?.[0], expected) } diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index a0145ebb3..c704ce294 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -40,6 +40,27 @@ const plans = new Map([ ]) } ], + [ + 'wallet-snapshot-journal', + { + fallback: 'revision', + files: new Map([ + ['src/storage/snapshot/journal/SnapshotJournalRevision.ts', 'revision'], + ['src/storage/snapshot/journal/SnapshotJournalRevisionSql.ts', 'revision'], + ['src/storage/snapshot/journal/SnapshotJournalPage.ts', 'page'], + ['src/storage/snapshot/journal/SnapshotJournalSqliteClock.ts', 'clock'], + ['src/storage/snapshot/journal/SnapshotJournalMysqlClock.ts', 'clock'], + ['src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts', 'sqlite-observers'], + ['src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts', 'mysql-observers'], + ['src/storage/snapshot/journal/SnapshotJournalBootstrap.ts', 'bootstrap'], + ['src/storage/snapshot/journal/SnapshotJournalHighWater.ts', 'high-water'], + ['src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts', 'mysql-source'], + ['src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', 'sqlite-generation'], + ['src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', 'mysql-intent'], + ['src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts', 'mysql-generation'] + ]) + } + ], [ 'wallet-snapshot-archive', { diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 1dcaf8ba1..1eeb4bbc3 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -280,6 +280,23 @@ for (const [id, expected, fallback] of [ ], 'lifecycle' ], + [ + 'wallet-snapshot-journal', + [ + 'revision', + 'page', + 'clock', + 'sqlite-observers', + 'mysql-observers', + 'bootstrap', + 'high-water', + 'mysql-source', + 'sqlite-generation', + 'mysql-intent', + 'mysql-generation' + ], + 'revision' + ], ['wallet-snapshot-archive', ['store', 'capture', 'source'], 'capture'], ['wallet-snapshot-remote-reader', ['admission', 'lease', 'rows', 'page'], 'admission'] ]) { diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 3150fa0f5..1e2b04b42 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -198,7 +198,7 @@ test('additional package-relative fixture inputs select their target without rep ) const canonical = buildMutationTargets(REPOSITORY_ROOT) - assert.equal(Object.keys(canonical).length, 46) + assert.equal(Object.keys(canonical).length, 47) assert.deepEqual(canonical['wallet-retained-snapshot'].additionalInputs, [ 'test/utils/snapshotRelationFixtures.ts', 'test/utils/snapshotCertificateFixtures.ts', @@ -240,3 +240,49 @@ test('retained snapshot mutation execution recycles workers while other wallet d assert.equal(targets['wallet-snapshot-archive'].runnerOptions.maxTestRunnerReuse, undefined) assert.equal(targets['wallet-snapshot-remote-http'].runnerOptions.maxTestRunnerReuse, undefined) }) + +test('journal mutation registration retains its complete source, canonical tests and fixture ownership', () => { + const target = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-journal'] + assert.deepEqual(target.mutate, [ + 'src/storage/snapshot/journal/SnapshotJournalRevision.ts', + 'src/storage/snapshot/journal/SnapshotJournalRevisionSql.ts', + 'src/storage/snapshot/journal/SnapshotJournalPage.ts', + 'src/storage/snapshot/journal/SnapshotJournalSqliteClock.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlClock.ts', + 'src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts', + 'src/storage/snapshot/journal/SnapshotJournalBootstrap.ts', + 'src/storage/snapshot/journal/SnapshotJournalHighWater.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts', + 'src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', + 'src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts' + ]) + assert.deepEqual(target.additionalInputs, [ + 'test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json', + 'test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json', + 'test/fixtures/snapshotJournal/mysql-generation-state-fixture.json', + 'test/fixtures/snapshotJournal/mysql-intent-metadata-fixture.json', + 'test/fixtures/snapshotJournal/mysql-source-metadata-fixture.json', + 'test/utils/snapshotArchiveFixtures.ts', + 'test/utils/snapshotSqliteFixtures.ts', + 'test/utils/snapshotHistoricalMigrations.ts', + 'test/storage/snapshotJournalNativeFixture.cjs', + 'test/storage/snapshotJournalMysqlConnection.cjs', + 'test/storage/snapshotJournalMysql.cjs', + 'test/storage/snapshotJournalMysqlServerCrash.cjs', + 'test/storage/snapshotJournalSqliteCrash.cjs', + 'test/storage/runSnapshotJournalMysql.cjs', + 'test/storage/snapshotArchiveDocker.cjs' + ]) + assert.equal(target.runnerOptions.maxTestRunnerReuse, 8) + assert.deepEqual(target.runnerOptions.jest.config.testMatch, [ + '/src/storage/snapshot/journal/*.test.ts' + ]) + for (const input of target.additionalInputs) + assert.ok( + selectAffectedMutationTargets(buildMutationTargets(REPOSITORY_ROOT), [ + 'packages/wallet/wallet-toolbox/' + input + ]).includes('wallet-snapshot-journal') + ) +}) diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 4c9128b59..82cbe9573 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 46) + assert.equal(result.summary.propertySuites, 47) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 5) assert.equal(result.summary.propertyClassifiedPackages, 36) - assert.equal(result.summary.mutationTargets, 46) + assert.equal(result.summary.mutationTargets, 47) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) From a6ef0167bff521446f52d06e3215f36bd048b7a6 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 06:02:59 -0700 Subject: [PATCH 086/127] test(wallet): qualify journal boundaries and native CI recovery --- .github/workflows/ci.yml | 12 ++ docs/guides/wallet-sync-reliability.md | 7 + packages/wallet/wallet-toolbox/README.md | 4 + .../journal/SnapshotJournal.property.test.ts | 95 ++++++++++-- .../journal/SnapshotJournalBootstrap.test.ts | 31 +++- .../journal/SnapshotJournalHighWater.test.ts | 68 ++++++++- .../SnapshotJournalMysqlBootstrap.test.ts | 95 +++++++++++- .../journal/SnapshotJournalMysqlClock.test.ts | 50 ++++++- .../SnapshotJournalMysqlGeneration.test.ts | 7 +- .../SnapshotJournalMysqlIntent.test.ts | 33 ++++- .../SnapshotJournalMysqlSource.test.ts | 140 ++++++++++++++++++ .../SnapshotJournalRevisionSql.test.ts | 24 +++ .../SnapshotJournalSqliteClock.test.ts | 41 +++++ .../SnapshotJournalSqliteGeneration.test.ts | 85 ++++++++++- scripts/ci-orchestration.test.mjs | 15 ++ 15 files changed, 673 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3e463bf3..922542783 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1033,6 +1033,18 @@ jobs: env: TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1' run: node test/storage/runSnapshotArchiveMysql.cjs + - name: Verify native journal SQLite recovery + if: matrix.id == 'shard-1' + working-directory: packages/wallet/wallet-toolbox + timeout-minutes: 3 + run: node test/storage/snapshotJournalSqliteCrash.cjs + - name: Verify native journal MySQL recovery + if: matrix.id == 'shard-1' + working-directory: packages/wallet/wallet-toolbox + timeout-minutes: 10 + env: + TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1' + run: node test/storage/runSnapshotJournalMysql.cjs - name: Generate wallet-toolbox coverage shard env: WALLET_SHARD: ${{ matrix.shard }} diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index f4f8ee1c1..71f071d90 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -1064,3 +1064,10 @@ uncovered/invalid mutants, 300 cases with seed 3242026, four workers, runner reu bounds remain in force. Native ownership, client/server process-loss and broader platform/performance evidence are separate required validation; source helpers alone do not establish deployment, replication, PXC or power-loss readiness. + +Required wallet CI shard 1 runs the SQLite journal process-loss fixture and the +owned disposable MySQL client/server recovery fixtures after the existing archive +proofs, using the same-head build. The journal steps have three- and ten-minute +job-step bounds respectively. Each mutation part enforces zero uncovered and +invalid mutants; the complete disjoint aggregate must also reach 90%. A successful +part or a retried native worker does not establish complete-target qualification. diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index a9f2d1619..ee43e1917 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -61,6 +61,10 @@ capture receipts, receiver/primary integration and the remaining issue #544 acceptance work are still required; see the [journal foundation](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#internal-journal-foundation-unadvertised). +Required wallet CI shard 1 also runs both native journal entry points from the +same-head build after the existing archive fixtures. The complete journal mutation +aggregate and native recovery checks are separate qualification requirements. + Both native archive entry points include interrupted auxiliary profile and numeric relation migrations through the real migrator. The numeric relation fixture terminates seven migration boundaries, repairs the abandoned migration lock and diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts index 352890bf1..03f609e08 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts @@ -93,6 +93,7 @@ describe('SnapshotJournalRevision', () => { '1'.repeat(10000) ])('refuses a noncanonical or out-of-range revision %p', value => { expect(() => snapshotJournalRevision(value)).toThrow(WERR_INVALID_OPERATION) + expect(() => snapshotJournalRevision(value)).toThrow('Invalid snapshot journal revision') }) test('canonical ordering agrees with independent bigint arithmetic through the entire range', () => { @@ -169,15 +170,18 @@ describe('SnapshotJournalRevision', () => { describe('SnapshotJournalPage', () => { const base = 9007199254740992n - const interval: SnapshotJournalInterval = { - stream: 'scope', - tableId: 12, - userId: 1, - floor: rev('0'), - low: rev(String(base)), - high: rev(String(base + 10n)), - limit: 2 - } + let interval: SnapshotJournalInterval + beforeEach(() => { + interval = { + stream: 'scope', + tableId: 12, + userId: 1, + floor: rev('0'), + low: rev(String(base)), + high: rev(String(base + 10n)), + limit: 2 + } + }) async function database(): Promise { const k = knex({ @@ -343,15 +347,15 @@ describe('SnapshotJournalPage', () => { { tableId: -1 }, { stream: 'physical', tableId: 12 }, { stream: 'unknown' }, - { high: rev('0') }, + { high: '0' }, { low: '01' }, { high: '9223372036854775808' }, - { after: { revision: interval.low, id1: 1, id2: 0, exactText: '' } }, - { after: { revision: rev(String(base + 11n)), id1: 1, id2: 0, exactText: '' } }, - { after: { revision: interval.high, id1: 0, id2: 0, exactText: '' } }, - { after: { revision: interval.high, id1: 1, id2: -1, exactText: '' } }, - { after: { revision: interval.high, id1: 1, id2: 0, exactText: '😀'.repeat(101) } }, - { after: { revision: interval.high, id1: 1, id2: 0, exactText: '\ud800' } } + { after: { revision: String(base), id1: 1, id2: 0, exactText: '' } }, + { after: { revision: String(base + 11n), id1: 1, id2: 0, exactText: '' } }, + { after: { revision: String(base + 10n), id1: 0, id2: 0, exactText: '' } }, + { after: { revision: String(base + 10n), id1: 1, id2: -1, exactText: '' } }, + { after: { revision: String(base + 10n), id1: 1, id2: 0, exactText: '😀'.repeat(101) } }, + { after: { revision: String(base + 10n), id1: 1, id2: 0, exactText: '\ud800' } } ])('malformed request refuses before SQL: %p', async change => { const k = await database() const seen = jest.fn() @@ -599,6 +603,7 @@ describe('SnapshotJournalSqliteObservers', () => { for (const table of tables) await k.schema.alterTable(table, t => { void t.text('payload') + void t.text('quoted"payload') void t.integer('updated_at').notNullable().defaultTo(0) }) const plan = await installGeneration(k) @@ -612,11 +617,17 @@ describe('SnapshotJournalSqliteObservers', () => { }) await exact(k) const before = await k('snapshot_journal_physical').orderBy(physicalKey.split(',')) + const scopeBefore = await k('snapshot_journal_scope').orderBy(scopeKey.split(',')) for (const table of tables) await k(table).update({ payload: 'same-timestamp update' }) await exact(k) const after = await k('snapshot_journal_physical').orderBy(physicalKey.split(',')) expect(after.map(r => r.generation)).toEqual(before.map(r => r.generation)) after.forEach((r, i) => expect(r.revision).toBeGreaterThan(before[i].revision)) + const scopeAfter = await k('snapshot_journal_scope').orderBy(scopeKey.split(',')) + expect(scopeAfter).toHaveLength(scopeBefore.length) + scopeAfter.forEach((row, i) => { + if (row.tableId !== 8 && row.tableId !== 9) expect(row.revision).toBeGreaterThan(scopeBefore[i].revision) + }) for (const table of tables) await k(table).update({ payload: 'same-timestamp update' }) expect(await k('snapshot_journal_physical').orderBy(physicalKey.split(','))).toEqual(after) await k.raw( @@ -836,6 +847,58 @@ describe('SnapshotJournalSqliteObservers', () => { return k } + test.each(tables)( + 'changing a %s key preserves the old tombstone and starts a new physical generation', + async table => { + const k = await journalFixture() + try { + const tableId = tables.indexOf(table) + await k(table).insert(value(table, 1, 1, 1)) + const before = await k('snapshot_journal_physical').where('tableId', tableId).first() + const original = keyOf(table, 1, 1) + const changed = keyOf(table, 2, 2) + await k(table).where(original).update(changed) + expect(await k(table).where(original)).toEqual([]) + expect(await k(table).where(changed)).toHaveLength(1) + const old = await k('snapshot_journal_physical') + .where({ + tableId, + id1: before.id1, + id2: before.id2, + exactText: before.exactText + }) + .first() + expect(old.present).toBe(0) + expect(old.generation).toBe(before.generation) + expect(old.revision).toBeGreaterThan(before.revision) + const current = await k('snapshot_journal_physical').where({ tableId, present: 1 }).first() + expect(current).toBeDefined() + expect(current.generation).toBeGreaterThan(before.generation) + expect(current.revision).toBe(current.generation) + } finally { + await k.destroy() + } + } + ) + + test.each(['sqlite3', 'better-sqlite3'])('SQLite observer alias %s prepares every table', async client => { + const k = await journalFixture() + k.client.config.client = client + try { + const definitions = await snapshotJournalSqliteObserverSql(k) + expect(definitions).toHaveLength(51) + } finally { + await k.destroy() + } + }) + + test('SQLite observer preparation refuses a foreign driver before querying its schema', async () => { + const raw = jest.fn(), + k = { client: { config: { client: 'mysql2' } }, raw } as unknown as Knex + await expect(snapshotJournalSqliteObserverSql(k)).rejects.toThrow('Snapshot journal observers require SQLite') + expect(raw).not.toHaveBeenCalled() + }) + test('all thirteen observers preserve exact revision and generation values above 2^53', async () => { const k = await journalFixture() try { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts index 70c1c831e..1a1ace263 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts @@ -5,7 +5,7 @@ import { } from './SnapshotJournalSqliteObservers' import { installSnapshotJournalSqliteClock } from './SnapshotJournalSqliteClock' import { snapshotJournalRevision } from './SnapshotJournalRevision' -// Outside-checkout design experiment. This is not a registered migration or API. +// Internal journal foundation. Registered migration and reader adoption remain separate. import { knex, type Knex } from 'knex' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -130,8 +130,11 @@ test('exact bootstrap copies bounded pages, resumes and preserves newer low-key complete = false, max = 0, insertQueries = 0 - const count = (q: { sql: string }) => { - if (/^insert into `snapshot_journal_(physical|scope)`/i.test(q.sql)) insertQueries++ + const count = (q: { sql: string; bindings: unknown[] }) => { + if (/^insert into `snapshot_journal_(physical|scope)`/i.test(q.sql)) { + insertQueries++ + expect(q.bindings.length).toBeLessThanOrEqual(64 * 7) + } } k.on('query', count) writer.on('query', count) @@ -320,3 +323,25 @@ test('malformed SQLite clock response rolls back allocation and bootstrap progre await k.destroy() } }) + +test('legacy SQLite alias bootstraps an exact 400-byte historical key', async () => { + const k = await emptyFixture() + k.client.config.client = 'sqlite3' + try { + const fieldName = '😀'.repeat(100) + await k('certificate_fields').insert({ ...value('certificate_fields', 1, 1, 1), fieldName }) + await beginBootstrap(k) + await k('snapshot_journal_bootstrap').update({ stream: 12 }) + expect(await copySnapshotJournalBootstrapPage(k)).toMatchObject({ + selected: 1, + invalidated: false + }) + expect((await k('snapshot_journal_physical').first()).exactText).toBe(fieldName) + expect(await copySnapshotJournalBootstrapPage(k)).toMatchObject({ + selected: 0, + invalidated: false + }) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.test.ts index 47cd2634a..f84d1e638 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalHighWater.test.ts @@ -118,7 +118,13 @@ test.each([ try { await expect( readSnapshotJournalHighWater(k, id, minimum as SnapshotJournalRevision, floor as SnapshotJournalRevision) - ).rejects.toThrow() + ).rejects.toThrow( + id < 1 || !Number.isSafeInteger(id) + ? 'Invalid snapshot journal profile' + : minimum === '01' + ? 'Invalid snapshot journal revision' + : 'Snapshot journal continuity was collected' + ) expect(queries).toBe(0) } finally { await k.destroy() @@ -155,3 +161,63 @@ test('pinned WAL heads remain coherent after an independent writer commits', asy await rm(directory, { recursive: true, force: true }) } }) + +test.each(['mysql', 'mysql2'])('MySQL high-water alias %s uses exact fifteen bounded index seeks', async client => { + const k = knex({ client: 'mysql2' }) + k.client.config.client = client + const queries: Array<{ sql: string; bindings: unknown[] }> = [] + const connection = { + query( + query: { sql: string }, + bindings: unknown[], + callback: (error: Error | null, rows?: unknown[], fields?: unknown[]) => void + ) { + queries.push({ sql: query.sql, bindings }) + const revisionText = queries.length === 9 ? '9007199254740997' : '9007199254740993' + callback(null, [{ revisionText }], []) + } + } + jest.spyOn(k.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(k.client, 'releaseConnection').mockResolvedValue(undefined) + try { + expect(await readSnapshotJournalHighWater(k, 41, rev('9007199254740992'), rev('0'))).toBe('9007199254740997') + expect(queries).toHaveLength(15) + queries.forEach((query, i) => { + const scope = i < 13, + table = scope ? 'scope' : 'physical', + tableId = scope ? i : i - 5 + expect(query.sql).toBe( + 'select CAST(`j`.`revision` AS CHAR) as `revisionText` from `snapshot_journal_' + + table + + '` AS `j` FORCE INDEX (`snapshot_journal_' + + table + + '_page`) where `j`.`tableId` = ?' + + (scope ? ' and `j`.`userId` = ?' : '') + + ' order by `j`.`revision` desc limit ?' + ) + expect(query.bindings).toEqual(scope ? [tableId, 41, 1] : [tableId, 1]) + }) + } finally { + await k.destroy() + } +}) + +test('SQLite legacy driver alias retains indexed exact high-water reads', async () => { + const k = open() + k.client.config.client = 'sqlite3' + try { + await install(k) + expect(await readSnapshotJournalHighWater(k, 1, rev('9007199254740993'), rev('0'))).toBe('9007199254740993') + } finally { + await k.destroy() + } +}) + +test('unsupported high-water driver refuses before query construction', async () => { + const raw = jest.fn(), + k = { client: { config: { client: 'unsupported' } }, raw } as unknown as Knex + await expect(readSnapshotJournalHighWater(k, 1, rev('0'), rev('0'))).rejects.toThrow( + 'Unsupported snapshot journal SQL driver' + ) + expect(raw).not.toHaveBeenCalled() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts index 6d56f08b2..a664c840f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts @@ -81,7 +81,8 @@ async function driver() { oversized: false, failMetadata: false, missingRevisionClock: false, - nonBinaryText: false + nonBinaryText: false, + invalidIdentity: undefined as unknown } const queries: Query[] = [] const connection = { @@ -147,6 +148,8 @@ async function driver() { const result = await db.raw(sql, q.bindings) if (state.nonBinaryText && Array.isArray(result) && q.sql.includes('`boundedText`')) for (const row of result) row.boundedText = 'not bytes' + if (Array.isArray(result) && sql.startsWith('select `s`.') && state.invalidIdentity !== undefined) + for (const row of result) row.transactionId = state.invalidIdentity if (Array.isArray(result)) return respond( state.oversized && sql.startsWith('select `s`.') ? Array.from({ length: 257 }, () => result[0]) : result @@ -248,7 +251,9 @@ test('MySQL bootstrap bounds each page and preserves an already observed newer g }) const page = await copySnapshotJournalBootstrapPage(f.k) expect(page.selected).toBe(256) - expect(f.queries.filter(q => q.sql.startsWith('insert into `snapshot_journal_physical`'))).toHaveLength(4) + const writes = f.queries.filter(q => q.sql.startsWith('insert into `snapshot_journal_physical`')) + expect(writes).toHaveLength(4) + writes.forEach(query => expect(query.bindings.length).toBeLessThanOrEqual(64 * 7)) const preserved = await f .db('snapshot_journal_physical') .where({ tableId: 0, id1: 1 }) @@ -428,3 +433,89 @@ test.each(['missingRevisionClock', 'nonBinaryText'] as const)( } } ) + +test.each([14, 15, 16])('MySQL stream %s preserves absent membership and its physical-table mapping', async stream => { + const f = await driver() + try { + const source = identities[stream], + extra = source.extra! + const present = sample(stream, 1), + absent = { ...sample(stream, 2), [extra]: 0 } + if (stream === 16) absent.tableId = 1 + await f.db('snapshot_journal_bootstrap').update({ stream }) + await f.db(source.table).insert([present, absent]) + expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + selected: 2, + invalidated: false + }) + const rows = await f.db('snapshot_journal_scope').orderBy('id1') + expect(rows.map(row => ({ tableId: row.tableId, id1: row.id1, present: row.present }))).toEqual([ + { tableId: stream === 14 ? 10 : stream === 15 ? 12 : 9, id1: 1, present: 1 }, + { tableId: stream === 14 ? 10 : stream === 15 ? 12 : 8, id1: 2, present: 0 } + ]) + } finally { + await f.close() + } +}) + +test.each(['10', '9223372036854775807'])('MySQL can allocate the exact final allowed revision %s', async ceiling => { + const f = await driver() + f.k.client.config.client = 'mysql' + try { + await f.db('transactions').insert({ transactionId: 1 }) + await f.db('snapshot_journal_clock').update({ ceiling }) + f.state.next = BigInt(ceiling) + expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + selected: 1, + invalidated: false + }) + expect(await f.db('snapshot_journal_physical').select(f.db.raw('CAST(revision AS TEXT) revision'))).toEqual([ + { revision: ceiling } + ]) + expect(await f.db('snapshot_journal_invalid')).toEqual([]) + } finally { + await f.close() + } +}) + +test.each([-1, 0, 1.5, 'invalid', 9007199254740992])( + 'MySQL invalid historical identity %p invalidates atomically and preserves source', + async transactionId => { + const f = await driver() + try { + const malformedResponse = typeof transactionId !== 'number' || !Number.isInteger(transactionId) + await f.db('transactions').insert({ transactionId: malformedResponse ? 1 : transactionId }) + if (malformedResponse) f.state.invalidIdentity = transactionId + expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + selected: 1, + invalidated: true + }) + expect(await f.db('snapshot_journal_invalid')).toEqual([{ id: 1, reason: 'key-out-of-range' }]) + expect(await f.db('snapshot_journal_physical')).toEqual([]) + expect((await f.db('snapshot_journal_bootstrap').first()).cursor).toBeNull() + expect((await f.db('transactions').first()).transactionId).toBe(malformedResponse ? 1 : transactionId) + } finally { + await f.close() + } + } +) + +test('MySQL accepts a 400-byte key through the final composite bootstrap cursor', async () => { + const f = await driver() + try { + const fieldName = '😀'.repeat(100) + await f.db('snapshot_journal_bootstrap').update({ stream: 12 }) + await f.db('certificate_fields').insert({ fieldName, certificateId: 1 }) + expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + selected: 1, + invalidated: false + }) + expect(Buffer.from((await f.db('snapshot_journal_physical').first()).exactText).toString('utf8')).toBe(fieldName) + expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + selected: 0, + invalidated: false + }) + } finally { + await f.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.test.ts index 4cff09458..686c0d68d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlClock.test.ts @@ -1,4 +1,6 @@ import { knex } from 'knex' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { installSnapshotJournalMysqlClock } from './SnapshotJournalMysqlClock' import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' @@ -27,10 +29,56 @@ test.each(['0', '-1', '01', '9223372036854775808'])( const query = jest.fn() k.on('query', query) try { - await expect(installSnapshotJournalMysqlClock(k, ceiling as SnapshotJournalRevision)).rejects.toThrow() + await expect(installSnapshotJournalMysqlClock(k, ceiling as SnapshotJournalRevision)).rejects.toThrow( + ceiling === '0' ? 'Empty snapshot journal event window' : 'Invalid snapshot journal revision' + ) expect(query).not.toHaveBeenCalled() } finally { await k.destroy() } } ) + +test.each(['mysql', 'mysql2'])( + 'MySQL clock alias %s creates its native objects and binds its exact ceiling', + async client => { + const k = knex({ client: 'mysql2' }) + k.client.config.client = client + const captured: { ddl: Array<{ sql: string; bindings: unknown[] }> } = JSON.parse( + readFileSync( + join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json'), + 'utf8' + ) + ) + const expected = captured.ddl.filter(row => /^CREATE TABLE snapshot_journal_(clock|events|invalid)\(/.test(row.sql)) + expect(expected).toHaveLength(3) + const queries: Array<{ sql: string; bindings: unknown[] }> = [] + const connection = { + query( + query: { sql: string }, + bindings: unknown[], + callback: (error: Error | null, rows?: unknown[], fields?: unknown[]) => void + ) { + queries.push({ sql: query.sql, bindings }) + callback(null, [], []) + } + } + jest.spyOn(k.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(k.client, 'releaseConnection').mockResolvedValue(undefined) + try { + await installSnapshotJournalMysqlClock(k, snapshotJournalRevision('9223372036854775807')) + // The generation owner adds its storage options, epoch and atomic initial row. + // This primitive emits the same base table shapes, then binds the clock row. + expect(queries.slice(0, 3)).toEqual( + expected.map(row => ({ sql: row.sql.split(' DEFAULT CHARACTER SET ')[0], bindings: [] })) + ) + expect(queries[3]).toEqual({ + sql: 'insert into `snapshot_journal_clock` (`ceiling`, `id`) values (?, ?)', + bindings: ['9223372036854775807', 1] + }) + expect(queries).toHaveLength(4) + } finally { + await k.destroy() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts index 852cb6933..47b99ccf8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts @@ -8,7 +8,7 @@ import { readSnapshotJournalMysqlGeneration } from './SnapshotJournalMysqlGeneration' import { readSnapshotJournalMysqlBinding } from './SnapshotJournalMysqlSource' -import { snapshotJournalRevision } from './SnapshotJournalRevision' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' jest.mock('./SnapshotJournalMysqlSource', () => ({ readSnapshotJournalMysqlBinding: jest.fn() })) interface Capture { sql: string @@ -24,7 +24,10 @@ const captured: Capture[] = JSON.parse( const nativeState = JSON.parse( readFileSync(join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-generation-state-fixture.json'), 'utf8') ) as Record -const ceiling = snapshotJournalRevision('9223372036854775807') +let ceiling: SnapshotJournalRevision +beforeEach(() => { + ceiling = snapshotJournalRevision('9223372036854775807') +}) const readBinding = jest.mocked(readSnapshotJournalMysqlBinding) const nativeDdl: { epoch: string; ddl: Array<{ sql: string; bindings: unknown[] }> } = JSON.parse( readFileSync(join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json'), 'utf8') diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.test.ts index b2b125c1f..ef35c456a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlIntent.test.ts @@ -7,11 +7,14 @@ import { type SnapshotJournalMysqlBinding } from './SnapshotJournalMysqlIntent' import { snapshotJournalRevision } from './SnapshotJournalRevision' -const binding: SnapshotJournalMysqlBinding = { - source: 'a'.repeat(64), - plan: 'b'.repeat(64), - ceiling: snapshotJournalRevision('9223372036854775807') -} +let binding: SnapshotJournalMysqlBinding +beforeEach(() => { + binding = { + source: 'a'.repeat(64), + plan: 'b'.repeat(64), + ceiling: snapshotJournalRevision('9223372036854775807') + } +}) const epoch = '12345678-1234-4123-8123-123456789abc' const captured: Record>> = JSON.parse( readFileSync(join(__dirname, '../../../../test/fixtures/snapshotJournal/mysql-intent-metadata-fixture.json'), 'utf8') @@ -84,7 +87,7 @@ test('native metadata and exact signed63 binding resume the persisted epoch', as await f.database.destroy() } }) -test.each(['8.0.21', '8.0.40-commercial', '8.4.0'])( +test.each(['8.0.21', '8.0.40-commercial', '8.4.0', '8.10.0'])( 'supported %s binds intent values and reads back its newly generated epoch', async version => { const f = await fixture() @@ -100,7 +103,7 @@ test.each(['8.0.21', '8.0.40-commercial', '8.4.0'])( } } ) -test.each(['8.0.20', '5.7.44', '10.11.8-MariaDB', '9.0.0', 'unknown'])( +test.each(['8.0.20', '5.7.44', '10.11.8-MariaDB', '9.0.0', 'unknown', 'prefix-8.4.0'])( 'unsupported atomic-DDL baseline %s performs no DDL', async version => { const f = await fixture() @@ -136,6 +139,10 @@ test('implicit DDL never commits a caller-owned transaction', async () => { }) test.each([ { source: 'A'.repeat(64) }, + { source: 'x' + 'a'.repeat(64) }, + { source: 'a'.repeat(64) + 'x' }, + { plan: 'x' + 'b'.repeat(64) }, + { plan: 'b'.repeat(64) + 'x' }, { plan: 'bad' }, { source: { toString: () => 'a'.repeat(64) } }, { plan: { toString: () => 'b'.repeat(64) } }, @@ -208,6 +215,8 @@ test.each([ { id: 2 }, { version: 2 }, { epoch: 'wrong' }, + { epoch: 'x' + epoch }, + { epoch: epoch + 'x' }, { source: 'c'.repeat(64) }, { plan: 'c'.repeat(64) }, { ceiling: '1' }, @@ -237,3 +246,13 @@ test.each(['missing', 'extra'])('%s intent row refuses', async kind => { await f.database.destroy() } }) + +test('legacy MySQL alias can resume its exact persisted intent', async () => { + const f = await fixture() + try { + f.k.client.config.client = 'mysql' + expect(await readSnapshotJournalMysqlIntent(f.k, binding)).toMatchObject({ ...binding, epoch }) + } finally { + await f.database.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.test.ts index be3330336..692a24bee 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.test.ts @@ -381,3 +381,143 @@ test.each(['missing', 'non-InnoDB', 'view'])( } } ) + +test.each(['mysql', 'mysql2'])('MySQL source alias %s accepts explicit display widths', async client => { + const k = await metadata() + k.client.config.client = client + try { + await k('COLUMNS') + .withSchema('information_schema') + .where('COLUMN_TYPE', 'int unsigned') + .update({ COLUMN_TYPE: 'int(10) unsigned' }) + await expect(validateSnapshotJournalMysqlSource(k)).resolves.toBeUndefined() + } finally { + await k.destroy() + } +}) + +test.each(['COLUMNS', 'STATISTICS'])('source validation alone bounds %s metadata at 512 entries', async table => { + const k = await metadata() + try { + const query = () => k(table).withSchema('information_schema') + const sourceTables = [ + 'transactions', + 'outputs', + 'certificates', + 'tx_labels', + 'output_baskets', + 'output_tags', + 'commissions', + 'sync_states', + 'proven_txs', + 'proven_tx_reqs', + 'tx_labels_map', + 'output_tags_map', + 'certificate_fields' + ] + const count = Number((await query().whereIn('TABLE_NAME', sourceTables).count('* AS n').first())!.n) + const example = await query().where('TABLE_NAME', 'outputs').first() + for (let i = count; i < 512; i++) + await query().insert( + table === 'COLUMNS' + ? { ...example, COLUMN_NAME: 'extra_' + i } + : { ...example, INDEX_NAME: 'extra_' + i, NON_UNIQUE: 1 } + ) + await expect(validateSnapshotJournalMysqlSource(k)).resolves.toBeUndefined() + await query().insert( + table === 'COLUMNS' + ? { ...example, COLUMN_NAME: 'overflow' } + : { ...example, INDEX_NAME: 'overflow', NON_UNIQUE: 1 } + ) + await expect(validateSnapshotJournalMysqlSource(k)).rejects.toThrow( + 'Unsupported or incomplete MySQL snapshot journal source' + ) + } finally { + await k.destroy() + } +}) + +test('a source owner cannot acquire auto-increment identity semantics', async () => { + const k = await metadata() + try { + await k('COLUMNS') + .withSchema('information_schema') + .where({ TABLE_NAME: 'tx_labels', COLUMN_NAME: 'userId' }) + .update({ EXTRA: 'auto_increment' }) + await expect(validateSnapshotJournalMysqlSource(k)).rejects.toThrow( + 'Unsupported or incomplete MySQL snapshot journal source' + ) + } finally { + await k.destroy() + } +}) + +test('every expected journal observer is excluded from the stable pre-install source binding', async () => { + const k = await metadata() + try { + const before = await readSnapshotJournalMysqlBinding(k) + const original = await k('TRIGGERS').withSchema('information_schema').first() + const names = [ + ...Array.from({ length: 4 }, (_, i) => 'snapshot_journal_scope_' + i), + ...Array.from({ length: 13 }, (_, i) => 'snapshot_journal_physical_' + i) + ].flatMap(prefix => ['INSERT', 'UPDATE', 'DELETE'].map(event => prefix + '_' + event)) + for (const name of names) + await k('TRIGGERS') + .withSchema('information_schema') + .insert({ ...original, TRIGGER_NAME: name, ACTION_STATEMENT: 'BEGIN DO 1; END' }) + expect(await readSnapshotJournalMysqlBinding(k)).toBe(before) + } finally { + await k.destroy() + } +}) + +test.each(['single oversized value', 'multibyte value', '513 rows', 'nonarray'])( + 'binding independently refuses %s from the driver', + async kind => { + const k = await metadata(), + process = jest.mocked(k.client.processResponse).getMockImplementation()! + let altered = false + jest.mocked(k.client.processResponse).mockImplementation((...args: unknown[]) => { + const query = args[0] as { sql: string }, + result = process(...args) + if (query.sql.includes('ORDINAL_POSITION position')) { + altered = true + if (kind === 'nonarray') return [null] + if (kind === '513 rows') + return [Array.from({ length: 513 }, (_, i) => ({ ...result[0][0], name: 'column_' + i }))] + result[0][0].defaultValue = kind === 'multibyte value' ? '😀'.repeat(4097) : 'x'.repeat(16385) + } + return result + }) + try { + await expect(readSnapshotJournalMysqlBinding(k)).rejects.toThrow( + 'Unsupported or incomplete MySQL snapshot journal source' + ) + expect(altered).toBe(true) + } finally { + await k.destroy() + } + } +) + +test('binding accepts exactly 512 driver rows and one 16384-byte definition', async () => { + const k = await metadata(), + process = jest.mocked(k.client.processResponse).getMockImplementation()! + let altered = false + jest.mocked(k.client.processResponse).mockImplementation((...args: unknown[]) => { + const query = args[0] as { sql: string }, + result = process(...args) + if (query.sql.includes('ORDINAL_POSITION position')) { + altered = true + while (result[0].length < 512) result[0].push({ name: 'extra_' + result[0].length }) + result[0][0].defaultValue = '😀'.repeat(4096) + } + return result + }) + try { + expect(await readSnapshotJournalMysqlBinding(k)).toMatch(/^[0-9a-f]{64}$/) + expect(altered).toBe(true) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.test.ts index ac055c547..78e9e7d74 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevisionSql.test.ts @@ -62,3 +62,27 @@ test.each(['pg', 'mssql', 'mysql-custom', undefined])('unknown driver %p refuses 'Unsupported snapshot journal SQL driver' ) }) + +test.each([ + ['better-sqlite3', 'TEXT', 'INTEGER'], + ['sqlite3', 'TEXT', 'INTEGER'], + ['mysql', 'CHAR', 'SIGNED'], + ['mysql2', 'CHAR', 'SIGNED'] +])('driver alias %s preserves the explicit selected and operand types', async (client, selected, operand) => { + const k = knex({ client: 'mysql2' }) + // Compile through an installed client; alias selection must not open a connection. + k.client.config.client = client + const query = jest.fn() + k.on('query', query) + try { + const text = snapshotJournalRevisionText(k, 'revision').toSQL() + const value = snapshotJournalRevisionOperand(k, snapshotJournalRevision('9223372036854775807')).toSQL() + expect(text.sql).toBe('CAST(`revision` AS ' + selected + ')') + expect(text.bindings).toEqual([]) + expect(value.sql).toBe('CAST(? AS ' + operand + ')') + expect(value.bindings).toEqual(['9223372036854775807']) + expect(query).not.toHaveBeenCalled() + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.test.ts index ec5373077..2c99ec55f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteClock.test.ts @@ -202,3 +202,44 @@ test('native clock constraints refuse invalid continuity states atomically', asy await k.destroy() } }) + +test.each(['sqlite3', 'better-sqlite3'])( + 'SQLite clock alias %s creates and advances an exact event window', + async client => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + k.client.config.client = client + try { + await installSnapshotJournalSqliteClock(k, rev('9007199254740993')) + expect( + await k('snapshot_journal_clock').select( + 'revision', + k.raw('CAST(ceiling AS TEXT) ceiling'), + 'enabled', + 'reason' + ) + ).toEqual([{ revision: 0, ceiling: '9007199254740993', enabled: 1, reason: null }]) + } finally { + await k.destroy() + } + } +) + +test('empty SQLite clock event window refuses with the stable error before DDL', async () => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }), + query = jest.fn() + k.on('query', query) + try { + await expect(installSnapshotJournalSqliteClock(k, rev('0'))).rejects.toThrow('Empty snapshot journal event window') + expect(query).not.toHaveBeenCalled() + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts index ec176be61..a2f5ceaf6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts @@ -10,8 +10,11 @@ import { SNAPSHOT_JOURNAL_SQLITE_GENERATION as metadata } from './SnapshotJournalSqliteGeneration' import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' -import { snapshotJournalRevision } from './SnapshotJournalRevision' -const ceiling = snapshotJournalRevision('1000000') +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' +let ceiling: SnapshotJournalRevision +beforeEach(() => { + ceiling = snapshotJournalRevision('1000000') +}) async function fixture() { const k = knex({ client: 'better-sqlite3', @@ -298,3 +301,81 @@ test.each(['users', 'settings'])('source metadata must include %s in the ownersh await source.destroy() } }) + +test('legacy SQLite alias preserves installation identity through completion', async () => { + const { k, source } = await fixture() + k.client.config.client = 'sqlite3' + try { + const installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + await finish(k) + expect(await completeSnapshotJournalSqliteGeneration(k)).toEqual({ + ...installed, + complete: true + }) + expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, complete: true }) + } finally { + await source.destroy() + } +}) + +test.each(['install', 'read', 'complete'])( + 'SQLite generation %s refuses a foreign client before I/O', + async operation => { + const transaction = jest.fn(), + raw = jest.fn() + const k = { client: { config: { client: 'mysql2' } }, transaction, raw } as unknown as Knex + const result = + operation === 'install' + ? installSnapshotJournalSqliteGeneration(k, ceiling) + : operation === 'read' + ? readSnapshotJournalSqliteGeneration(k) + : completeSnapshotJournalSqliteGeneration(k) + await expect(result).rejects.toThrow('Invalid or unowned SQLite snapshot journal generation') + expect(transaction).not.toHaveBeenCalled() + expect(raw).not.toHaveBeenCalled() + } +) + +test('empty SQLite generation event window refuses before transaction admission', async () => { + const transaction = jest.fn(), + k = { client: { config: { client: 'sqlite3' } }, transaction } as unknown as Knex + await expect(installSnapshotJournalSqliteGeneration(k, snapshotJournalRevision('0'))).rejects.toThrow( + 'Invalid or unowned SQLite snapshot journal generation' + ) + expect(transaction).not.toHaveBeenCalled() +}) + +test.each(['prefix', 'suffix'])( + 'SQLite generation rejects an epoch with a valid UUID only as a %s substring', + async side => { + const { k, source } = await fixture() + try { + const installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + await k(metadata).update({ + epoch: side === 'prefix' ? installed.epoch + 'x' : 'x' + installed.epoch + }) + await expect(readSnapshotJournalSqliteGeneration(k)).rejects.toThrow( + 'Invalid or unowned SQLite snapshot journal generation' + ) + } finally { + await source.destroy() + } + } +) + +test.each(['revision-exhausted', 'key-out-of-range'])( + 'SQLite %s state remains readable but cannot publish completion', + async reason => { + const { k, source } = await fixture() + try { + const installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + await k('snapshot_journal_clock').update({ enabled: 0, reason }) + expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, enabled: false }) + await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toThrow( + 'Invalid or unowned SQLite snapshot journal generation' + ) + } finally { + await source.destroy() + } + } +) diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 32a9a8fd2..fe30043ec 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -519,3 +519,18 @@ test('the mutation quality job accepts skipped execution only for explicitly emp }) assert.notEqual(failedBuild.status, 0) }) + +test('journal native recovery remains mandatory alongside all archive fixture families', () => { + const { wallet, steps } = nativeWalletFixtureSteps() + const archive = onlyFixtureStep(steps, 'node test/storage/runSnapshotArchiveMysql.cjs') + const sqlite = onlyFixtureStep(steps, 'node test/storage/snapshotJournalSqliteCrash.cjs') + const mysql = onlyFixtureStep(steps, 'node test/storage/runSnapshotJournalMysql.cjs') + const coverage = steps.findIndex(step => + step.includes('name: Generate wallet-toolbox coverage shard') + ) + assert.match(steps[sqlite], /^ timeout-minutes: 3$/m) + assert.match(steps[mysql], /^ timeout-minutes: 10$/m) + assert.ok(steps[mysql].includes("TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1'")) + assert.ok(archive < sqlite && sqlite < mysql && mysql < coverage) + assertNativeWalletJob(wallet) +}) From 2734d26d4f9807743c34739f61e89a16e8488a30 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 07:44:26 -0700 Subject: [PATCH 087/127] feat(wallet): bound journal bootstrap allocation and index probes --- docs/guides/wallet-sync-reliability.md | 21 +- docs/reference/package-api-migrations.md | 74 +- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/README.md | 3 +- .../journal/SnapshotJournalBootstrap.test.ts | 211 +++- .../journal/SnapshotJournalBootstrap.ts | 47 +- .../SnapshotJournalMysqlBootstrap.test.ts | 217 +++- .../SnapshotJournalMysqlGeneration.test.ts | 21 +- .../journal/SnapshotJournalMysqlGeneration.ts | 34 +- .../SnapshotJournalPrerequisites.test.ts | 2 +- .../SnapshotJournalSqliteGeneration.test.ts | 6 +- .../SnapshotJournalSqliteGeneration.ts | 8 +- .../journal/SnapshotJournalSqliteObservers.ts | 12 +- .../mysql-generation-ddl-fixture.json | 126 +- .../mysql-generation-metadata-fixture.json | 1021 ++++++----------- .../mysql-generation-state-fixture.json | 11 +- .../test/storage/snapshotJournalMysql.cjs | 50 +- .../snapshotJournalMysqlServerCrash.cjs | 37 +- .../storage/snapshotJournalSqliteCrash.cjs | 34 +- 19 files changed, 1066 insertions(+), 873 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 71f071d90..ce471b9fa 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -1047,7 +1047,26 @@ generations bind source schema and typed object ownership; MySQL persists an atomic first intent and resumes each implicit-DDL step using the same epoch. Unexpected objects or changed metadata refuse adoption. Standard source rows are preserved by these helpers. Schema changes and concurrent migrators must be -excluded by the operator during installation or resumption. +excluded by the operator during installation or resumption. SQLite observer +source probes use native point-key equality plus an exact-byte residual, retaining +BINARY/NOCASE/RTRIM identity while admitting the complete source key index. + +Bootstrap callers supply an explicit row allowance from zero through 2,147,483,647. +The first copy transaction persists it; later pages require the same allowance. +Each page owns its transaction and refuses a caller transaction before accessing +the journal, preventing a retained writer barrier or an older MySQL progress view. +Each examined record consumes one unit, including a key already created by a live +observer, so the accounting does not depend on driver affected-row semantics. +Charges and progress commit atomically. An over-budget page inserts no metadata, +leaves the cursor and charge unchanged, and commits `capacity-exhausted` +invalidation; standard source writes remain available. Raising the allowance +requires a new owned generation. This bounds bootstrap allocations and does not +add quota accounting to ordinary MySQL source transactions. It does not bound +physical database/WAL bytes, event metadata or receipt retention. + +Native recovery fixtures include allowance binding, metadata insertion, progress +update and commit boundaries, then independently count the fully bootstrapped +streams to detect lost or repeated charges after recovery. This foundation adds no registered migration, public capability or reader advertisement. Its event-window invalidation is not a complete retention or diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 68c796072..49364e9fe 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. -- Migration: The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. +- Release note: Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. +- Migration: Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 90f9d3e00..cf1468be9 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records.", - "migration": "The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding." + "summary": "Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records.", + "migration": "Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index ee43e1917..0b112d047 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -52,7 +52,8 @@ owned work and attempts exact-owner cleanup before reporting its outcome; unproved cleanup fails qualification. Other wallet shards do not start MySQL. The candidate also contains internal SQL journal primitives for exact revisions, -bounded metadata pages and bootstrap, source-table observers, and recovery of an +bounded metadata pages, bootstrap with a durable explicit row allowance, +source-table observers, and recovery of an interrupted journal installation. Run `pnpm test:snapshot-journal-crash` for the SQLite process-loss fixture and `pnpm test:snapshot-journal-mysql` for the isolated MySQL client/server-process recovery fixtures. These helpers do not register a diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts index 1a1ace263..4038e9a63 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.test.ts @@ -3,7 +3,7 @@ import { snapshotJournalSqliteObserverSql, SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL } from './SnapshotJournalSqliteObservers' -import { installSnapshotJournalSqliteClock } from './SnapshotJournalSqliteClock' +import { installSnapshotJournalSqliteClock, SNAPSHOT_JOURNAL_SQLITE_WRITABLE } from './SnapshotJournalSqliteClock' import { snapshotJournalRevision } from './SnapshotJournalRevision' // Internal journal foundation. Registered migration and reader adoption remain separate. import { knex, type Knex } from 'knex' @@ -86,7 +86,7 @@ async function exact(k: Knex) { async function beginBootstrap(k: Knex) { await installCandidate(k, false) await k.raw(SNAPSHOT_JOURNAL_BOOTSTRAP_DDL) - await k('snapshot_journal_bootstrap').insert({ id: 1, stream: 0, cursor: null }) + await k('snapshot_journal_bootstrap').insert({ id: 1, stream: 0, cursor: null, rowLimit: 1000000, rowsUsed: 0 }) } test('exact bootstrap copies bounded pages, resumes and preserves newer low-key observers', async () => { @@ -140,7 +140,7 @@ test('exact bootstrap copies bounded pages, resumes and preserves newer low-key writer.on('query', count) for (let page = 0; page < 100; page++) { insertQueries = 0 - const result = await copySnapshotJournalBootstrapPage(page % 2 ? writer : k) + const result = await copySnapshotJournalBootstrapPage(page % 2 ? writer : k, 1000000) expect(insertQueries).toBeLessThanOrEqual(4) expect(result.invalidated).toBe(false) max = Math.max(max, result.selected) @@ -174,7 +174,7 @@ test('exact bootstrap copies bounded pages, resumes and preserves newer low-key expect(BigInt(row.revision)).toBeGreaterThan(9007199254740992n) expect(BigInt(row.generation)).toBeGreaterThan(9007199254740992n) } - expect(await copySnapshotJournalBootstrapPage(k)).toEqual({ + expect(await copySnapshotJournalBootstrapPage(k, 1000000)).toEqual({ complete: true, selected: 0, stream: 17, @@ -212,7 +212,7 @@ test('oversized historical text is bounded before decode and disables bootstrap if (row?.boundedText instanceof Uint8Array) maximum = Math.max(maximum, row.boundedText.length) } k.on('query-response', record) - const page = await copySnapshotJournalBootstrapPage(k) + const page = await copySnapshotJournalBootstrapPage(k, 1000000) k.off('query-response', record) expect(page).toEqual({ complete: false, selected: 1, stream: 12, invalidated: true }) expect(maximum).toBe(401) @@ -227,7 +227,9 @@ test('oversized historical text is bounded before decode and disables bootstrap expect(await k('snapshot_journal_bootstrap').first()).toEqual({ id: 1, stream: 12, - cursor: null + cursor: null, + rowLimit: 1000000, + rowsUsed: 0 }) } finally { await k.destroy() @@ -244,7 +246,7 @@ test.each(['capacity', 'signed63'])('%s exhaustion commits invalidation without ? { revision: 1, ceiling: 1 } : { revision: '9223372036854775807', ceiling: '9223372036854775807' } ) - expect(await copySnapshotJournalBootstrapPage(k)).toEqual({ + expect(await copySnapshotJournalBootstrapPage(k, 1000000)).toEqual({ complete: false, selected: 1, stream: 0, @@ -253,7 +255,9 @@ test.each(['capacity', 'signed63'])('%s exhaustion commits invalidation without expect(await k('snapshot_journal_bootstrap').first()).toEqual({ id: 1, stream: 0, - cursor: null + cursor: null, + rowLimit: 1000000, + rowsUsed: 0 }) expect(await k('snapshot_journal_physical')).toEqual([]) expect(await k('transactions').count('* AS n').first()).toEqual({ n: 1 }) @@ -269,7 +273,7 @@ test.each(['{', '{}', '[]', '["wrong-type"]', '[9007199254740992]', '[1,2]'])( try { await beginBootstrap(k) await k('snapshot_journal_bootstrap').update({ cursor }) - await expect(copySnapshotJournalBootstrapPage(k)).rejects.toThrow('bootstrap state') + await expect(copySnapshotJournalBootstrapPage(k, 1000000)).rejects.toThrow('bootstrap state') expect(await k('snapshot_journal_physical')).toEqual([]) } finally { await k.destroy() @@ -285,7 +289,7 @@ test('historical BLOB field spelling invalidates instead of changing its SQL com ) await beginBootstrap(k) await k('snapshot_journal_bootstrap').update({ stream: 12 }) - expect((await copySnapshotJournalBootstrapPage(k)).invalidated).toBe(true) + expect((await copySnapshotJournalBootstrapPage(k, 1000000)).invalidated).toBe(true) expect(await k('snapshot_journal_physical')).toEqual([]) expect(await k('certificate_fields').select(k.raw('typeof(fieldName) kind')).first()).toEqual({ kind: 'blob' @@ -309,14 +313,16 @@ test('malformed SQLite clock response rolls back allocation and bootstrap progre await beginBootstrap(k) const before = await k('snapshot_journal_clock').first() k.on('query-response', listener) - await expect(copySnapshotJournalBootstrapPage(k)).rejects.toThrow('Invalid snapshot') + await expect(copySnapshotJournalBootstrapPage(k, 1000000)).rejects.toThrow('Invalid snapshot') expect(altered).toBe(true) expect(await k('snapshot_journal_clock').first()).toEqual(before) expect(await k('snapshot_journal_physical')).toEqual([]) expect(await k('snapshot_journal_bootstrap').first()).toEqual({ id: 1, stream: 0, - cursor: null + cursor: null, + rowLimit: 1000000, + rowsUsed: 0 }) } finally { k.off('query-response', listener) @@ -332,12 +338,12 @@ test('legacy SQLite alias bootstraps an exact 400-byte historical key', async () await k('certificate_fields').insert({ ...value('certificate_fields', 1, 1, 1), fieldName }) await beginBootstrap(k) await k('snapshot_journal_bootstrap').update({ stream: 12 }) - expect(await copySnapshotJournalBootstrapPage(k)).toMatchObject({ + expect(await copySnapshotJournalBootstrapPage(k, 1000000)).toMatchObject({ selected: 1, invalidated: false }) expect((await k('snapshot_journal_physical').first()).exactText).toBe(fieldName) - expect(await copySnapshotJournalBootstrapPage(k)).toMatchObject({ + expect(await copySnapshotJournalBootstrapPage(k, 1000000)).toMatchObject({ selected: 0, invalidated: false }) @@ -345,3 +351,180 @@ test('legacy SQLite alias bootstraps an exact 400-byte historical key', async () await k.destroy() } }) + +test('SQLite bootstrap commits over-budget invalidation without source loss or partial metadata', async () => { + const k = await emptyFixture() + try { + await k('transactions').insert([value('transactions', 1, 1, 1), value('transactions', 2, 2, 1)]) + await beginBootstrap(k) + await k('snapshot_journal_bootstrap').update({ rowLimit: null }) + const clock = await k('snapshot_journal_clock').first() + expect(await copySnapshotJournalBootstrapPage(k, 1)).toEqual({ + complete: false, + selected: 2, + stream: 0, + invalidated: true + }) + expect(await k('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 0, + cursor: null, + rowLimit: 1, + rowsUsed: 0 + }) + expect(await k('snapshot_journal_physical')).toEqual([]) + expect(await k('snapshot_journal_scope')).toEqual([]) + expect(await k('snapshot_journal_clock').first()).toEqual({ ...clock, enabled: 0, reason: 'capacity-exhausted' }) + await k('transactions').insert(value('transactions', 3, 3, 1)) + expect(await k('transactions')).toHaveLength(3) + expect(await k('snapshot_journal_physical')).toEqual([]) + await expect(copySnapshotJournalBootstrapPage(k, 3)).rejects.toThrow('Invalid snapshot') + } finally { + await k.destroy() + } +}) + +test('SQLite bootstrap commits the exact allowance with its cursor and preserves it on resume', async () => { + const k = await emptyFixture() + try { + await k('transactions').insert([value('transactions', 1, 1, 1), value('transactions', 2, 2, 1)]) + await beginBootstrap(k) + await k('snapshot_journal_bootstrap').update({ rowLimit: null }) + expect((await copySnapshotJournalBootstrapPage(k, 2)).invalidated).toBe(false) + expect(await k('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 0, + cursor: '[2]', + rowLimit: 2, + rowsUsed: 2 + }) + expect(await k('snapshot_journal_physical')).toHaveLength(2) + expect((await copySnapshotJournalBootstrapPage(k, 2)).selected).toBe(0) + expect(await k('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 1, + cursor: null, + rowLimit: 2, + rowsUsed: 2 + }) + await expect(copySnapshotJournalBootstrapPage(k, 1)).rejects.toThrow('Invalid snapshot') + } finally { + await k.destroy() + } +}) + +test('SQLite bootstrap rolls back a newly bound allowance, charge, cursor and revision on failed metadata', async () => { + const k = await emptyFixture() + try { + await k('transactions').insert(value('transactions', 1, 1, 1)) + await beginBootstrap(k) + await k('snapshot_journal_bootstrap').update({ rowLimit: null }) + const clock = await k('snapshot_journal_clock').first() + await k.raw( + "CREATE TRIGGER fail_bootstrap_metadata BEFORE INSERT ON snapshot_journal_physical BEGIN SELECT RAISE(ABORT,'fixture metadata failure'); END" + ) + await expect(copySnapshotJournalBootstrapPage(k, 1)).rejects.toThrow('fixture metadata failure') + expect(await k('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 0, + cursor: null, + rowLimit: null, + rowsUsed: 0 + }) + expect(await k('snapshot_journal_clock').first()).toEqual(clock) + expect(await k('snapshot_journal_physical')).toEqual([]) + await k.raw('DROP TRIGGER fail_bootstrap_metadata') + await copySnapshotJournalBootstrapPage(k, 1) + expect((await k('snapshot_journal_bootstrap').first()).rowsUsed).toBe(1) + } finally { + await k.destroy() + } +}) + +test.each([0, 2147483647])('SQLite bootstrap accepts empty-stream budget boundary %s', async allowance => { + const k = await emptyFixture() + try { + await beginBootstrap(k) + await k('snapshot_journal_bootstrap').update({ rowLimit: null }) + expect((await copySnapshotJournalBootstrapPage(k, allowance)).selected).toBe(0) + expect(await k('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 1, + cursor: null, + rowLimit: allowance, + rowsUsed: 0 + }) + } finally { + await k.destroy() + } +}) + +test.each(['BINARY', 'NOCASE', 'RTRIM'])( + 'SQLite journal source probes use complete point indexes under %s without widening byte identity', + async collation => { + const k = await fixture(collation, false, false) + try { + for (const table of tables) await k(table).insert(value(table, 1, 2, 1)) + const definitions = await snapshotJournalSqliteObserverSql(k) + for (const [tableId, table] of tables.entries()) { + const definition = definitions.find(sql => + sql.startsWith('CREATE TRIGGER snapshot_journal_physical_' + tableId + '_INSERT ') + )! + const start = definition.indexOf('EXISTS(') + 'EXISTS('.length + const end = definition.indexOf(') WHERE ' + SNAPSHOT_JOURNAL_SQLITE_WRITABLE, start) + expect(start).toBeGreaterThan('EXISTS('.length) + expect(end).toBeGreaterThan(start) + const row = value(table, 1, 2, 1) + const names: string[] = [] + const query = definition + .slice(start, end) + .replace( + /NEW\.(?:"([^"]+)"|([A-Za-z][A-Za-z0-9_]*))/g, + (_match, quoted: string | undefined, bare: string | undefined) => { + const name = (quoted ?? bare)! + names.push(name) + // Driver numeric bindings are doubles; NEW/OLD key columns are native integers. + return typeof row[name] === 'number' ? 'CAST(? AS INTEGER)' : '?' + } + ) + const bindings = names.map(name => row[name]) as Knex.RawBinding[] + const plan: Array<{ detail: string }> = await k.raw('EXPLAIN QUERY PLAN ' + query, bindings) + expect(plan.some(step => step.detail.startsWith('SCAN s'))).toBe(false) + const search = plan.find(step => step.detail.startsWith('SEARCH s ')) + expect(search).toBeDefined() + expect(search!.detail.match(/=\?/g)).toHaveLength(tableId < 10 ? 1 : 2) + expect(await k.raw(query, bindings)).toHaveLength(1) + if (table === 'certificate_fields') { + for (const alternate of [String(row.fieldName).toUpperCase(), String(row.fieldName) + ' ']) { + const changed = names.map(name => (name === 'fieldName' ? alternate : row[name])) as Knex.RawBinding[] + expect(await k.raw(query, changed)).toHaveLength(0) + } + } + } + } finally { + await k.destroy() + } + } +) + +test('SQLite bootstrap rejects caller transactions before any journal access', async () => { + const k = await emptyFixture() + try { + await beginBootstrap(k) + const before = await k('snapshot_journal_bootstrap').first() + await k.transaction(async t => { + const queries: string[] = [] + const observe = (query: { sql: string }) => queries.push(query.sql) + t.on('query', observe) + try { + await expect(copySnapshotJournalBootstrapPage(t, 1000000)).rejects.toThrow('Invalid snapshot') + expect(queries).toEqual([]) + } finally { + t.off('query', observe) + } + }) + expect(await k('snapshot_journal_bootstrap').first()).toEqual(before) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts index ea89557fc..e9b80a59b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts @@ -13,7 +13,7 @@ import { import { snapshotJournalRevisionText, snapshotJournalRevisionOperand } from './SnapshotJournalRevisionSql' export const SNAPSHOT_JOURNAL_BOOTSTRAP_DDL = - 'CREATE TABLE snapshot_journal_bootstrap(id INTEGER NOT NULL PRIMARY KEY,stream INTEGER NOT NULL,`cursor` TEXT,CHECK(id=1),CHECK(stream BETWEEN 0 AND 17))' + 'CREATE TABLE snapshot_journal_bootstrap(id INTEGER NOT NULL PRIMARY KEY,stream INTEGER NOT NULL,`cursor` TEXT,rowLimit INTEGER,rowsUsed INTEGER NOT NULL,CHECK(id=1),CHECK(stream BETWEEN 0 AND 17),CHECK(rowLimit IS NULL OR rowLimit BETWEEN 0 AND 2147483647),CHECK(rowsUsed BETWEEN 0 AND 2147483647),CHECK(rowLimit IS NULL OR rowsUsed<=rowLimit))' interface Stream { table: string keys: string[] @@ -22,6 +22,17 @@ interface Stream { physical: boolean record: (row: Record) => Record } +/** A conservative charge counts every examined bootstrap record, including an + * already-observed key. The bound survives retries without counting SQL affected + * rows, whose semantics differ between the two drivers. */ +export function validSnapshotJournalBootstrapBudget(row: { rowLimit: unknown; rowsUsed: unknown }): boolean { + const bounded = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 2147483647 + return ( + bounded(row.rowsUsed) && + (row.rowLimit === null ? row.rowsUsed === 0 : bounded(row.rowLimit) && row.rowsUsed <= row.rowLimit) + ) +} function invalid(): never { throw new WERR_INVALID_OPERATION('Invalid snapshot journal bootstrap state or source order') } @@ -248,8 +259,19 @@ export interface SnapshotJournalBootstrapPage { stream: number invalidated: boolean } -/** Fresh/resumed owned state only. Caller must validate migration ownership before every resume. */ -export async function copySnapshotJournalBootstrapPage(k: Knex): Promise { +/** Fresh/resumed owned state only. Caller validates migration ownership before + * every resume and supplies an explicit row allowance. The first page persists + * that allowance; changing it requires a new generation. This bounds bootstrap + * allocation, separately from event-window and physical/receipt retention limits. + * Owns its transaction: a caller transaction could retain the writer barrier + * beyond this page or reuse an older MySQL progress/capacity read view. + */ +export async function copySnapshotJournalBootstrapPage( + k: Knex, + rowLimit: number +): Promise { + if (k.isTransaction || !validSnapshotJournalBootstrapBudget({ rowLimit, rowsUsed: 0 }) || rowLimit === null) + return invalid() const local = sqlite(k), all = streams(local) return await k.transaction(async t => { @@ -258,21 +280,26 @@ export async function copySnapshotJournalBootstrapPage(k: Knex): Promise all.length || - !(state.cursor === null || typeof state.cursor === 'string') + !(state.cursor === null || typeof state.cursor === 'string') || + !validSnapshotJournalBootstrapBudget(state) || + (state.rowLimit !== null && state.rowLimit !== rowLimit) || + (state.rowLimit === null && (state.stream !== 0 || state.cursor !== null)) ) return invalid() const enabled = local ? (await t('snapshot_journal_clock').where('id', 1).first('enabled'))?.enabled === 1 : !(await t('snapshot_journal_invalid').where('id', 1).first('id')) if (!enabled) return { complete: false, selected: 0, stream: state.stream, invalidated: true } + if (state.rowLimit === null) { + await t('snapshot_journal_bootstrap').where('id', 1).update({ rowLimit }) + } if (state.stream === all.length) { if (state.cursor !== null) return invalid() return { complete: true, selected: 0, stream: state.stream, invalidated: false } @@ -303,6 +330,10 @@ export async function copySnapshotJournalBootstrapPage(k: Knex): Promise rowLimit - state.rowsUsed) { + await invalidate(t, local, 'capacity-exhausted') + return { complete: false, selected: rows.length, stream: state.stream, invalidated: true } + } if (records.length) { const at = await revision(t, local) if (at === undefined) return { complete: false, selected: rows.length, stream: state.stream, invalidated: true } @@ -334,7 +365,7 @@ export async function copySnapshotJournalBootstrapPage(k: Knex): Promise last[key])) } + ? { cursor: JSON.stringify(stream.keys.map(key => last[key])), rowsUsed: state.rowsUsed + rows.length } : { stream: state.stream + 1, cursor: null } ) return { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts index a664c840f..10ab1e09c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlBootstrap.test.ts @@ -158,7 +158,7 @@ async function driver() { } await installSnapshotJournalMysqlClock(k, rev('9223372036854775807')) await db.raw(SNAPSHOT_JOURNAL_BOOTSTRAP_DDL) - await db('snapshot_journal_bootstrap').insert({ id: 1, stream: 0, cursor: null }) + await db('snapshot_journal_bootstrap').insert({ id: 1, stream: 0, cursor: null, rowLimit: 1000000, rowsUsed: 0 }) for (const ddl of SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL) await db.raw(ddl) for (const source of identities) { const columns = [...source.keys, ...(source.extra ? [source.extra] : [])].map( @@ -209,7 +209,7 @@ test.each(identities.map((source, stream) => [source.table, stream] as const))( const source = identities[stream] await f.db('snapshot_journal_bootstrap').update({ stream, cursor: null }) await f.db(source.table).insert([sample(stream, 1), sample(stream, 2)]) - const first = await copySnapshotJournalBootstrapPage(f.k) + const first = await copySnapshotJournalBootstrapPage(f.k, 1000000) expect(first).toEqual({ complete: false, selected: 2, stream, invalidated: false }) const metadata = stream < 13 ? 'snapshot_journal_physical' : 'snapshot_journal_scope', rows = await f.db(metadata).select('*', f.db.raw('CAST(revision AS TEXT) AS exactRevision')) @@ -218,13 +218,15 @@ test.each(identities.map((source, stream) => [source.table, stream] as const))( expect(rows.every(row => row.exactText instanceof Uint8Array)).toBe(true) expect(await f.db('snapshot_journal_events')).toEqual([]) await f.db(source.table).insert(sample(stream, 3)) - expect((await copySnapshotJournalBootstrapPage(f.k)).selected).toBe(1) - const final = await copySnapshotJournalBootstrapPage(f.k) + expect((await copySnapshotJournalBootstrapPage(f.k, 1000000)).selected).toBe(1) + const final = await copySnapshotJournalBootstrapPage(f.k, 1000000) expect(final).toEqual({ complete: stream === 16, selected: 0, stream, invalidated: false }) expect(await f.db('snapshot_journal_bootstrap').first()).toEqual({ id: 1, stream: stream + 1, - cursor: null + cursor: null, + rowLimit: 1000000, + rowsUsed: 3 }) expect(f.queries.some(q => q.sql.endsWith('for update nowait'))).toBe(true) expect( @@ -249,7 +251,7 @@ test('MySQL bootstrap bounds each page and preserves an already observed newer g generation: '9007199254740998', present: 0 }) - const page = await copySnapshotJournalBootstrapPage(f.k) + const page = await copySnapshotJournalBootstrapPage(f.k, 1000000) expect(page.selected).toBe(256) const writes = f.queries.filter(q => q.sql.startsWith('insert into `snapshot_journal_physical`')) expect(writes).toHaveLength(4) @@ -264,7 +266,7 @@ test('MySQL bootstrap bounds each page and preserves an already observed newer g revision: '9007199254740999', generation: '9007199254740998' }) - expect((await copySnapshotJournalBootstrapPage(f.k)).selected).toBe(44) + expect((await copySnapshotJournalBootstrapPage(f.k, 1000000)).selected).toBe(44) } finally { await f.close() } @@ -278,7 +280,7 @@ test.each([ await f.db('transactions').insert({ transactionId: 1 }) await f.db('snapshot_journal_clock').update({ ceiling }) f.state.next = BigInt(next) - expect(await copySnapshotJournalBootstrapPage(f.k)).toEqual({ + expect(await copySnapshotJournalBootstrapPage(f.k, 1000000)).toEqual({ complete: false, selected: 1, stream: 0, @@ -288,7 +290,7 @@ test.each([ expect(await f.db('snapshot_journal_physical')).toEqual([]) expect((await f.db('snapshot_journal_bootstrap').first()).cursor).toBeNull() expect(await f.db('transactions')).toEqual([{ transactionId: 1 }]) - expect((await copySnapshotJournalBootstrapPage(f.k)).invalidated).toBe(true) + expect((await copySnapshotJournalBootstrapPage(f.k, 1000000)).invalidated).toBe(true) } finally { await f.close() } @@ -298,7 +300,7 @@ test.each([null, 1, '-1', '1.5', 'bad'])('MySQL malformed allocator value %p rol try { await f.db('transactions').insert({ transactionId: 1 }) f.state.allocated = value - await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + await expect(copySnapshotJournalBootstrapPage(f.k, 1000000)).rejects.toThrow('Invalid snapshot') expect(await f.db('snapshot_journal_events')).toEqual([]) expect(await f.db('snapshot_journal_physical')).toEqual([]) expect((await f.db('snapshot_journal_bootstrap').first()).cursor).toBeNull() @@ -313,7 +315,7 @@ test.each(['filesort', 'noIndex', 'oversized', 'failMetadata'] as const)( try { await f.db('transactions').insert({ transactionId: 1 }) f.state[kind] = true - await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow() + await expect(copySnapshotJournalBootstrapPage(f.k, 1000000)).rejects.toThrow() expect(await f.db('snapshot_journal_physical')).toEqual([]) expect((await f.db('snapshot_journal_bootstrap').first()).cursor).toBeNull() } finally { @@ -326,7 +328,7 @@ test('MySQL oversized historical UTF-8 key invalidates without copying or changi try { await f.db('snapshot_journal_bootstrap').update({ stream: 12 }) await f.db('certificate_fields').insert({ fieldName: 'a'.repeat(401), certificateId: 1 }) - expect((await copySnapshotJournalBootstrapPage(f.k)).invalidated).toBe(true) + expect((await copySnapshotJournalBootstrapPage(f.k, 1000000)).invalidated).toBe(true) expect(await f.db('snapshot_journal_invalid')).toEqual([{ id: 1, reason: 'key-out-of-range' }]) expect(await f.db('snapshot_journal_physical')).toEqual([]) expect((await f.db('certificate_fields').first()).fieldName).toHaveLength(401) @@ -375,7 +377,7 @@ test.each(['clock', 'bootstrap'])('missing MySQL %s state refuses rather than cr const f = await driver() try { await f.db('snapshot_journal_' + kind).delete() - await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + await expect(copySnapshotJournalBootstrapPage(f.k, 1000000)).rejects.toThrow('Invalid snapshot') expect(await f.db('snapshot_journal_physical')).toEqual([]) } finally { await f.close() @@ -388,7 +390,7 @@ test.each(['not-json', '[]', '[-1]', '[1.5]', '["1"]', 'x'.repeat(2049)])( try { await f.db('transactions').insert({ transactionId: 1 }) await f.db('snapshot_journal_bootstrap').update({ cursor }) - await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + await expect(copySnapshotJournalBootstrapPage(f.k, 1000000)).rejects.toThrow('Invalid snapshot') expect(await f.db('snapshot_journal_physical')).toEqual([]) expect(await f.db('transactions')).toEqual([{ transactionId: 1 }]) } finally { @@ -400,14 +402,14 @@ test('completed MySQL bootstrap is stable and a dangling terminal cursor refuses const f = await driver() try { await f.db('snapshot_journal_bootstrap').update({ stream: 17 }) - expect(await copySnapshotJournalBootstrapPage(f.k)).toEqual({ + expect(await copySnapshotJournalBootstrapPage(f.k, 1000000)).toEqual({ complete: true, selected: 0, stream: 17, invalidated: false }) await f.db('snapshot_journal_bootstrap').update({ cursor: '[]' }) - await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + await expect(copySnapshotJournalBootstrapPage(f.k, 1000000)).rejects.toThrow('Invalid snapshot') } finally { await f.close() } @@ -424,7 +426,7 @@ test.each(['missingRevisionClock', 'nonBinaryText'] as const)( const clock = await f.db('snapshot_journal_clock'), progress = await f.db('snapshot_journal_bootstrap') f.state[kind] = true - await expect(copySnapshotJournalBootstrapPage(f.k)).rejects.toThrow('Invalid snapshot') + await expect(copySnapshotJournalBootstrapPage(f.k, 1000000)).rejects.toThrow('Invalid snapshot') expect(await f.db('snapshot_journal_physical')).toEqual([]) expect(await f.db('snapshot_journal_clock')).toEqual(clock) expect(await f.db('snapshot_journal_bootstrap')).toEqual(progress) @@ -444,7 +446,7 @@ test.each([14, 15, 16])('MySQL stream %s preserves absent membership and its phy if (stream === 16) absent.tableId = 1 await f.db('snapshot_journal_bootstrap').update({ stream }) await f.db(source.table).insert([present, absent]) - expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + expect(await copySnapshotJournalBootstrapPage(f.k, 1000000)).toMatchObject({ selected: 2, invalidated: false }) @@ -465,7 +467,7 @@ test.each(['10', '9223372036854775807'])('MySQL can allocate the exact final all await f.db('transactions').insert({ transactionId: 1 }) await f.db('snapshot_journal_clock').update({ ceiling }) f.state.next = BigInt(ceiling) - expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + expect(await copySnapshotJournalBootstrapPage(f.k, 1000000)).toMatchObject({ selected: 1, invalidated: false }) @@ -486,7 +488,7 @@ test.each([-1, 0, 1.5, 'invalid', 9007199254740992])( const malformedResponse = typeof transactionId !== 'number' || !Number.isInteger(transactionId) await f.db('transactions').insert({ transactionId: malformedResponse ? 1 : transactionId }) if (malformedResponse) f.state.invalidIdentity = transactionId - expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + expect(await copySnapshotJournalBootstrapPage(f.k, 1000000)).toMatchObject({ selected: 1, invalidated: true }) @@ -506,12 +508,12 @@ test('MySQL accepts a 400-byte key through the final composite bootstrap cursor' const fieldName = '😀'.repeat(100) await f.db('snapshot_journal_bootstrap').update({ stream: 12 }) await f.db('certificate_fields').insert({ fieldName, certificateId: 1 }) - expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + expect(await copySnapshotJournalBootstrapPage(f.k, 1000000)).toMatchObject({ selected: 1, invalidated: false }) expect(Buffer.from((await f.db('snapshot_journal_physical').first()).exactText).toString('utf8')).toBe(fieldName) - expect(await copySnapshotJournalBootstrapPage(f.k)).toMatchObject({ + expect(await copySnapshotJournalBootstrapPage(f.k, 1000000)).toMatchObject({ selected: 0, invalidated: false }) @@ -519,3 +521,174 @@ test('MySQL accepts a 400-byte key through the final composite bootstrap cursor' await f.close() } }) + +test('MySQL bootstrap charges an explicit immutable allowance across resumed pages', async () => { + const f = await driver() + try { + await f.db('snapshot_journal_bootstrap').update({ rowLimit: null }) + for (let first = 1; first <= 300; first += 50) + await f.db('transactions').insert(Array.from({ length: 50 }, (_, offset) => sample(0, first + offset))) + expect(await copySnapshotJournalBootstrapPage(f.k, 300)).toMatchObject({ selected: 256, invalidated: false }) + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 0, + cursor: '[256]', + rowLimit: 300, + rowsUsed: 256 + }) + const prior = await f.db('snapshot_journal_bootstrap').first() + await expect(copySnapshotJournalBootstrapPage(f.k, 301)).rejects.toThrow('Invalid snapshot') + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual(prior) + expect(await copySnapshotJournalBootstrapPage(f.k, 300)).toMatchObject({ selected: 44, invalidated: false }) + const full = await f.db('snapshot_journal_bootstrap').first() + expect(full).toEqual({ id: 1, stream: 0, cursor: '[300]', rowLimit: 300, rowsUsed: 300 }) + await f.db('transactions').insert(sample(0, 301)) + expect(await copySnapshotJournalBootstrapPage(f.k, 300)).toEqual({ + complete: false, + selected: 1, + stream: 0, + invalidated: true + }) + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual(full) + expect(await f.db('snapshot_journal_invalid')).toEqual([{ id: 1, reason: 'capacity-exhausted' }]) + expect(Number((await f.db('snapshot_journal_physical').count('* AS n').first())!.n)).toBe(300) + expect(Number((await f.db('transactions').count('* AS n').first())!.n)).toBe(301) + await expect(copySnapshotJournalBootstrapPage(f.k, 301)).rejects.toThrow('Invalid snapshot') + expect((await copySnapshotJournalBootstrapPage(f.k, 300)).selected).toBe(0) + } finally { + await f.close() + } +}) + +test('MySQL bootstrap refuses a whole over-budget page before allocation or metadata writes', async () => { + const f = await driver() + try { + await f.db('snapshot_journal_bootstrap').update({ rowLimit: null }) + await f.db('transactions').insert([sample(0, 1), sample(0, 2)]) + const sequence = f.state.next + expect(await copySnapshotJournalBootstrapPage(f.k, 1)).toEqual({ + complete: false, + selected: 2, + stream: 0, + invalidated: true + }) + expect(f.state.next).toBe(sequence) + expect(await f.db('snapshot_journal_physical')).toEqual([]) + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 0, + cursor: null, + rowLimit: 1, + rowsUsed: 0 + }) + expect(await f.db('transactions')).toHaveLength(2) + } finally { + await f.close() + } +}) + +test('MySQL bootstrap rolls back first allowance binding and charge with failed metadata', async () => { + const f = await driver() + try { + await f.db('snapshot_journal_bootstrap').update({ rowLimit: null }) + await f.db('transactions').insert(sample(0, 1)) + f.state.failMetadata = true + await expect(copySnapshotJournalBootstrapPage(f.k, 1)).rejects.toThrow('metadata write failed') + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 0, + cursor: null, + rowLimit: null, + rowsUsed: 0 + }) + expect(await f.db('snapshot_journal_physical')).toEqual([]) + f.state.failMetadata = false + await copySnapshotJournalBootstrapPage(f.k, 1) + expect((await f.db('snapshot_journal_bootstrap').first()).rowsUsed).toBe(1) + await copySnapshotJournalBootstrapPage(f.k, 1) + expect((await f.db('snapshot_journal_bootstrap').first()).rowsUsed).toBe(1) + } finally { + await f.close() + } +}) + +test('MySQL bootstrap charges examined records even when live metadata already owns the key', async () => { + const f = await driver() + try { + await f.db('snapshot_journal_bootstrap').update({ rowLimit: null }) + await f.db('transactions').insert(sample(0, 1)) + const live = { tableId: 0, id1: 1, id2: 0, exactText: Buffer.from(''), revision: 9, generation: 7, present: 0 } + await f.db('snapshot_journal_physical').insert(live) + await copySnapshotJournalBootstrapPage(f.k, 1) + expect(await f.db('snapshot_journal_physical').first()).toEqual(live) + expect((await f.db('snapshot_journal_bootstrap').first()).rowsUsed).toBe(1) + } finally { + await f.close() + } +}) + +test.each([0, 2147483647])('MySQL bootstrap persists boundary allowance %s for an empty stream', async allowance => { + const f = await driver() + try { + await f.db('snapshot_journal_bootstrap').update({ rowLimit: null }) + expect(await copySnapshotJournalBootstrapPage(f.k, allowance)).toEqual({ + complete: false, + selected: 0, + stream: 0, + invalidated: false + }) + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual({ + id: 1, + stream: 1, + cursor: null, + rowLimit: allowance, + rowsUsed: 0 + }) + } finally { + await f.close() + } +}) + +test.each([-1, 0.5, 2147483648, NaN, Infinity, '1', undefined, null])( + 'MySQL bootstrap rejects invalid allowance %s before I/O', + async allowance => { + const f = await driver() + try { + await expect(copySnapshotJournalBootstrapPage(f.k, allowance as number)).rejects.toThrow('Invalid snapshot') + expect(f.queries).toEqual([]) + } finally { + await f.close() + } + } +) + +test.each([{ rowsUsed: 0.5 }, { rowLimit: 0.5 }, { rowLimit: null, rowsUsed: 1 }])( + 'MySQL bootstrap refuses malformed persisted budget %j', + async patch => { + const f = await driver() + try { + await f.db('snapshot_journal_bootstrap').update(patch) + const before = await f.db('snapshot_journal_bootstrap').first() + await expect(copySnapshotJournalBootstrapPage(f.k, 1000000)).rejects.toThrow('Invalid snapshot') + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual(before) + expect(await f.db('snapshot_journal_physical')).toEqual([]) + } finally { + await f.close() + } + } +) + +test('MySQL bootstrap rejects caller transactions before a nested transaction or progress read', async () => { + const f = await driver() + try { + const before = await f.db('snapshot_journal_bootstrap').first() + await f.k.transaction(async t => { + const count = f.queries.length + await expect(copySnapshotJournalBootstrapPage(t, 1000000)).rejects.toThrow('Invalid snapshot') + expect(f.queries).toHaveLength(count) + }) + expect(await f.db('snapshot_journal_bootstrap').first()).toEqual(before) + } finally { + await f.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts index 47b99ccf8..56febd41a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts @@ -44,8 +44,16 @@ async function fixture() { await database('snapshot_journal_generation').insert(nativeState) await database.raw('CREATE TABLE snapshot_journal_clock(id INTEGER,ceiling TEXT)') await database('snapshot_journal_clock').insert({ id: 1, ceiling }) - await database.raw('CREATE TABLE snapshot_journal_bootstrap(id INTEGER,stream INTEGER,cursor TEXT)') - await database('snapshot_journal_bootstrap').insert({ id: 1, stream: 17, cursor: null }) + await database.raw( + 'CREATE TABLE snapshot_journal_bootstrap(id INTEGER,stream INTEGER,cursor TEXT,rowLimit INTEGER,rowsUsed INTEGER)' + ) + await database('snapshot_journal_bootstrap').insert({ + id: 1, + stream: 17, + cursor: null, + rowLimit: 1000000, + rowsUsed: 0 + }) await database.raw('CREATE TABLE snapshot_journal_invalid(id INTEGER,reason TEXT)') await database.raw('CREATE TABLE snapshot_journal_events(revision TEXT)') const metadata = structuredClone(captured), @@ -99,7 +107,8 @@ async function fixture() { complete: 0 }) if (name === 'snapshot_journal_clock') await database(name).insert({ id: 1, ceiling: values![0] }) - if (name === 'snapshot_journal_bootstrap') await database(name).insert({ id: 1, stream: 0, cursor: null }) + if (name === 'snapshot_journal_bootstrap') + await database(name).insert({ id: 1, stream: 0, cursor: null, rowLimit: null, rowsUsed: 0 }) available.add(name) fail('after:' + name) return {} @@ -198,7 +207,7 @@ test('lost final object acknowledgement resumes its epoch and advances only the const f = await fixture() try { await f.database('snapshot_journal_generation').update({ nextObject: 56, complete: 0 }) - await f.database('snapshot_journal_bootstrap').update({ stream: 0 }) + await f.database('snapshot_journal_bootstrap').update({ stream: 0, rowLimit: null }) f.writes.length = 0 const resumed = await installSnapshotJournalMysqlGeneration(f.k, ceiling) expect(resumed).toMatchObject({ @@ -460,7 +469,9 @@ test('fresh installation uses the independently captured native DDL and atomical expect(state).toMatchObject({ nextObject: 57, complete: false, enabled: true }) expect(state.epoch).not.toBe(nativeState.epoch) expect(await f.database('snapshot_journal_clock')).toEqual([{ id: 1, ceiling }]) - expect(await f.database('snapshot_journal_bootstrap')).toEqual([{ id: 1, stream: 0, cursor: null }]) + expect(await f.database('snapshot_journal_bootstrap')).toEqual([ + { id: 1, stream: 0, cursor: null, rowLimit: null, rowsUsed: 0 } + ]) expect(objectNames).toHaveLength(58) } finally { await f.database.destroy() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts index 4cb40f0f2..03eff12e0 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts @@ -4,7 +4,7 @@ import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' import { runInSeries } from '../../../utility/runInSeries' import { SNAPSHOT_JOURNAL_MYSQL_CLOCK_DDL } from './SnapshotJournalMysqlClock' import { SNAPSHOT_JOURNAL_MYSQL_METADATA_DDL, snapshotJournalMysqlObserverSql } from './SnapshotJournalMysqlObservers' -import { SNAPSHOT_JOURNAL_BOOTSTRAP_DDL } from './SnapshotJournalBootstrap' +import { SNAPSHOT_JOURNAL_BOOTSTRAP_DDL, validSnapshotJournalBootstrapBudget } from './SnapshotJournalBootstrap' import { readSnapshotJournalMysqlBinding } from './SnapshotJournalMysqlSource' import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' import { @@ -130,9 +130,21 @@ function tables(): Table[] { }, { name: 'snapshot_journal_bootstrap', - columns: [integer('id'), integer('stream'), { ...column('cursor', 'text'), nullable: true }], + columns: [ + integer('id'), + integer('stream'), + { ...column('cursor', 'text'), nullable: true }, + { ...integer('rowLimit'), nullable: true }, + integer('rowsUsed') + ], indexes: [primary('id')], - checks: ['(`id` = 1)', '(`stream` between 0 and 17)'], + checks: [ + '(`id` = 1)', + '(`stream` between 0 and 17)', + '((`rowLimit` is null) or (`rowLimit` between 0 and 2147483647))', + '(`rowsUsed` between 0 and 2147483647)', + '((`rowLimit` is null) or (`rowsUsed` <= `rowLimit`))' + ], seed: 'bootstrap' } ] @@ -312,7 +324,14 @@ async function validateObject( } if (object.definition.seed === 'bootstrap' && state.nextObject < 57) { const progress = await k('snapshot_journal_bootstrap').select('*').limit(2) - if (progress.length !== 1 || progress[0].id !== 1 || progress[0].stream !== 0 || progress[0].cursor !== null) + if ( + progress.length !== 1 || + progress[0].id !== 1 || + progress[0].stream !== 0 || + progress[0].cursor !== null || + progress[0].rowLimit !== null || + progress[0].rowsUsed !== 0 + ) return invalid() } return @@ -395,7 +414,9 @@ async function validateState( row.stream > 17 || !(row.cursor === null || (typeof row.cursor === 'string' && Buffer.byteLength(row.cursor, 'utf8') <= 2048)) || (row.stream === 17 && row.cursor !== null) || - (state.complete && row.stream !== 17) + (state.complete && row.stream !== 17) || + !validSnapshotJournalBootstrapBudget(row) || + (row.rowLimit === null && (row.stream !== 0 || row.cursor !== null)) ) return invalid() const invalidations = await k('snapshot_journal_invalid').select('*').limit(2) @@ -437,7 +458,8 @@ export async function installSnapshotJournalMysqlGeneration( const table = object.definition const sql = table.sql + " COMMENT='" + owner(state.epoch) + "'" if (table.seed === 'clock') await k.raw(sql + ' SELECT 1 id,? ceiling', [ceiling]) - else if (table.seed === 'bootstrap') await k.raw(sql + ' SELECT 1 id,0 stream,NULL `cursor`') + else if (table.seed === 'bootstrap') + await k.raw(sql + ' SELECT 1 id,0 stream,NULL `cursor`,NULL rowLimit,0 rowsUsed') else await k.raw(sql) } await validateObject(k, object, state, p.context) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts index 27faba7fb..b06984e8f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts @@ -10,7 +10,7 @@ import { snapshotJournalMysqlObserverSql } from './SnapshotJournalMysqlObservers import { snapshotJournalRevision as rev } from './SnapshotJournalRevision' test.each([ - ['bootstrap', (k: Knex) => copySnapshotJournalBootstrapPage(k)], + ['bootstrap', (k: Knex) => copySnapshotJournalBootstrapPage(k, 1000000)], ['SQLite generation read', (k: Knex) => readSnapshotJournalSqliteGeneration(k)], ['SQLite generation completion', (k: Knex) => completeSnapshotJournalSqliteGeneration(k)], ['high-water', (k: Knex) => readSnapshotJournalHighWater(k, 1, rev('0'), rev('0'))], diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts index a2f5ceaf6..f3addedc9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts @@ -36,7 +36,7 @@ async function fixture() { } } async function finish(k: Knex) { - for (let page = 0; page < 80; page++) if ((await copySnapshotJournalBootstrapPage(k)).complete) return + for (let page = 0; page < 80; page++) if ((await copySnapshotJournalBootstrapPage(k, 1000000)).complete) return throw new Error('bootstrap did not finish') } @@ -48,7 +48,7 @@ test('atomic installation resumes its epoch and publishes only completed durable expect(installed).toMatchObject({ complete: false, enabled: true, ceiling }) expect(installed.epoch).toMatch(/^[0-9a-f-]{36}$/) await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') - await copySnapshotJournalBootstrapPage(k) + await copySnapshotJournalBootstrapPage(k, 1000000) const progress = await k('snapshot_journal_bootstrap').first() expect(await installSnapshotJournalSqliteGeneration(k, ceiling)).toEqual(installed) expect(await k('snapshot_journal_bootstrap').first()).toEqual(progress) @@ -150,7 +150,7 @@ test('configured event exhaustion preserves source writes and refuses completion await k('tx_labels').where('txLabelId', 1).update({ label: 'ordinary after exhaustion' }) expect((await k('tx_labels').where('txLabelId', 1).first()).label).toBe('ordinary after exhaustion') expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, enabled: false }) - await k('snapshot_journal_bootstrap').update({ stream: 17, cursor: null }) + await k('snapshot_journal_bootstrap').update({ stream: 17, cursor: null, rowLimit: 1000000 }) await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') } finally { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts index 9b68040e5..10d05a159 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts @@ -10,7 +10,7 @@ import { snapshotJournalSqliteObserverSql, SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL } from './SnapshotJournalSqliteObservers' -import { SNAPSHOT_JOURNAL_BOOTSTRAP_DDL } from './SnapshotJournalBootstrap' +import { SNAPSHOT_JOURNAL_BOOTSTRAP_DDL, validSnapshotJournalBootstrapBudget } from './SnapshotJournalBootstrap' import { snapshotJournalRevision, compareSnapshotJournalRevisions, @@ -144,7 +144,9 @@ async function validate(k: Knex, p: Plan): Promise { right = tuple(table, alias) return left.map((key, i) => `CAST(${key} AS BLOB) IS CAST(${right[i]} AS BLOB)`).join(' AND ') } +/** Native equality admits the declared point index; the byte residual keeps + * generation identity exact even for NOCASE/RTRIM certificate-field keys. */ +const sourceWhere = (table: string, prefix: string, alias: string) => { + const left = tuple(table, prefix), + right = tuple(table, alias) + const indexed = right.flatMap((key, i) => (key.startsWith(alias + '.') ? [`${key}=${left[i]}`] : [])) + return indexed.join(' AND ') + ' AND ' + exactWhere(table, prefix, alias) +} function keyGuard(key: string[], owner?: string): string { const [id1, id2, text] = key const integer = (value: string, minimum: number) => @@ -109,7 +117,7 @@ export async function snapshotJournalSqliteObserverSql(k: Knex): Promise - `INSERT INTO snapshot_journal_physical(${physicalKey},revision,generation,present) SELECT ${tableId},${tuple(table, p).join(',')},${revision},${revision},EXISTS(SELECT 1 FROM ${q(table)} s WHERE ${exactWhere(table, p, 's')}) WHERE ${writable} ON CONFLICT(${physicalKey}) DO UPDATE SET revision=excluded.revision,generation=CASE WHEN ${fresh} THEN excluded.generation ELSE snapshot_journal_physical.generation END,present=excluded.present; ` + `INSERT INTO snapshot_journal_physical(${physicalKey},revision,generation,present) SELECT ${tableId},${tuple(table, p).join(',')},${revision},${revision},EXISTS(SELECT 1 FROM ${q(table)} s WHERE ${sourceWhere(table, p, 's')}) WHERE ${writable} ON CONFLICT(${physicalKey}) DO UPDATE SET revision=excluded.revision,generation=CASE WHEN ${fresh} THEN excluded.generation ELSE snapshot_journal_physical.generation END,present=excluded.present; ` let body = keyGuard(current, tableId < 8 ? prefix + '.userId' : undefined) if (event === 'UPDATE') body += keyGuard(tuple(table, 'OLD'), tableId < 8 ? 'OLD.userId' : undefined) body += tick @@ -117,7 +125,7 @@ export async function snapshotJournalSqliteObserverSql(k: Knex): PromisejournalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_0_INSERT AFTER INSERT ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_0_DELETE AFTER DELETE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_0_DELETE AFTER DELETE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_0_UPDATE AFTER UPDATE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_0_UPDATE AFTER UPDATE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_1_INSERT AFTER INSERT ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_1_INSERT AFTER INSERT ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_1_DELETE AFTER DELETE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_1_DELETE AFTER DELETE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_1_UPDATE AFTER UPDATE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_1_UPDATE AFTER UPDATE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_2_INSERT AFTER INSERT ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_2_INSERT AFTER INSERT ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_2_DELETE AFTER DELETE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_2_DELETE AFTER DELETE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_2_UPDATE AFTER UPDATE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_2_UPDATE AFTER UPDATE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_3_INSERT AFTER INSERT ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_3_INSERT AFTER INSERT ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_3_DELETE AFTER DELETE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_3_DELETE AFTER DELETE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_3_UPDATE AFTER UPDATE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_3_UPDATE AFTER UPDATE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_0_INSERT AFTER INSERT ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_0_INSERT AFTER INSERT ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_0_UPDATE AFTER UPDATE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_0_UPDATE AFTER UPDATE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_0_DELETE AFTER DELETE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_0_DELETE AFTER DELETE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_1_INSERT AFTER INSERT ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_1_INSERT AFTER INSERT ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_1_UPDATE AFTER UPDATE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_1_UPDATE AFTER UPDATE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_1_DELETE AFTER DELETE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_1_DELETE AFTER DELETE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_2_INSERT AFTER INSERT ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_2_INSERT AFTER INSERT ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_2_UPDATE AFTER UPDATE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_2_UPDATE AFTER UPDATE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_2_DELETE AFTER DELETE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_2_DELETE AFTER DELETE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_3_INSERT AFTER INSERT ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_3_INSERT AFTER INSERT ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_3_UPDATE AFTER UPDATE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_3_UPDATE AFTER UPDATE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_3_DELETE AFTER DELETE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_3_DELETE AFTER DELETE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_4_INSERT AFTER INSERT ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_4_INSERT AFTER INSERT ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_4_UPDATE AFTER UPDATE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_4_UPDATE AFTER UPDATE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_4_DELETE AFTER DELETE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_4_DELETE AFTER DELETE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_5_INSERT AFTER INSERT ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_5_INSERT AFTER INSERT ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_5_UPDATE AFTER UPDATE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_5_UPDATE AFTER UPDATE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_5_DELETE AFTER DELETE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_5_DELETE AFTER DELETE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_6_INSERT AFTER INSERT ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_6_INSERT AFTER INSERT ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_6_UPDATE AFTER UPDATE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_6_UPDATE AFTER UPDATE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_6_DELETE AFTER DELETE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_6_DELETE AFTER DELETE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_7_INSERT AFTER INSERT ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_7_INSERT AFTER INSERT ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_7_UPDATE AFTER UPDATE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_7_UPDATE AFTER UPDATE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_7_DELETE AFTER DELETE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_7_DELETE AFTER DELETE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_8_INSERT AFTER INSERT ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_8_INSERT AFTER INSERT ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_8_UPDATE AFTER UPDATE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_8_UPDATE AFTER UPDATE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_8_DELETE AFTER DELETE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_8_DELETE AFTER DELETE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_9_INSERT AFTER INSERT ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_9_INSERT AFTER INSERT ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_9_UPDATE AFTER UPDATE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_9_UPDATE AFTER UPDATE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_9_DELETE AFTER DELETE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_9_DELETE AFTER DELETE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_10_INSERT AFTER INSERT ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_10_INSERT AFTER INSERT ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_10_UPDATE AFTER UPDATE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_10_UPDATE AFTER UPDATE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_10_DELETE AFTER DELETE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_10_DELETE AFTER DELETE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_11_INSERT AFTER INSERT ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_11_INSERT AFTER INSERT ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_11_UPDATE AFTER UPDATE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_11_UPDATE AFTER UPDATE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_11_DELETE AFTER DELETE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_11_DELETE AFTER DELETE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_12_INSERT AFTER INSERT ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_12_INSERT AFTER INSERT ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_12_UPDATE AFTER UPDATE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_12_UPDATE AFTER UPDATE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_12_DELETE AFTER DELETE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:8f342f6f-9112-4323-bdad-dafdf3e0f9e0 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_12_DELETE AFTER DELETE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] } ] -} \ No newline at end of file +} diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json index 33c675796..a3bc65d94 100644 --- a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json @@ -1,27 +1,19 @@ [ { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "knex_migrations" - ] + "bindings": ["knex_migrations"] }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_profile_keys" - ] + "bindings": ["snapshot_profile_keys"] }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_profile_index_progress" - ] + "bindings": ["snapshot_profile_index_progress"] }, { "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_profile_keys" - ], + "bindings": ["snapshot_profile_keys"], "rows": [ { "name": "snapshotTableId", @@ -48,9 +40,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", - "bindings": [ - "snapshot_profile_keys" - ], + "bindings": ["snapshot_profile_keys"], "rows": [ { "name": "PRIMARY", @@ -77,9 +67,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_profile_index_progress" - ], + "bindings": ["snapshot_profile_index_progress"], "rows": [ { "name": "snapshotTableId", @@ -106,9 +94,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", - "bindings": [ - "snapshot_profile_index_progress" - ], + "bindings": ["snapshot_profile_index_progress"], "rows": [ { "name": "PRIMARY", @@ -121,21 +107,15 @@ }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_relation_keys" - ] + "bindings": ["snapshot_relation_keys"] }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_relation_index_progress" - ] + "bindings": ["snapshot_relation_index_progress"] }, { "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_relation_keys" - ], + "bindings": ["snapshot_relation_keys"], "rows": [ { "name": "snapshotTableId", @@ -176,9 +156,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", - "bindings": [ - "snapshot_relation_keys" - ], + "bindings": ["snapshot_relation_keys"], "rows": [ { "name": "PRIMARY", @@ -268,9 +246,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_relation_index_progress" - ], + "bindings": ["snapshot_relation_index_progress"], "rows": [ { "name": "snapshotTableId", @@ -304,9 +280,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", - "bindings": [ - "snapshot_relation_index_progress" - ], + "bindings": ["snapshot_relation_index_progress"], "rows": [ { "name": "PRIMARY", @@ -319,22 +293,15 @@ }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_certificate_field_keys" - ] + "bindings": ["snapshot_certificate_field_keys"] }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_certificate_index_progress" - ] + "bindings": ["snapshot_certificate_index_progress"] }, { "sql": "SELECT TABLE_NAME AS name, ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?, ?) ORDER BY TABLE_NAME", - "bindings": [ - "certificate_fields", - "certificates" - ], + "bindings": ["certificate_fields", "certificates"], "rows": [ { "name": "certificate_fields", @@ -348,10 +315,7 @@ }, { "sql": "SELECT COLUMN_TYPE AS type, IS_NULLABLE AS nullable, CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", - "bindings": [ - "certificate_fields", - "fieldName" - ], + "bindings": ["certificate_fields", "fieldName"], "rows": [ { "type": "varchar(100)", @@ -363,9 +327,7 @@ }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", - "bindings": [ - "snapshot_certificate_field_keys" - ], + "bindings": ["snapshot_certificate_field_keys"], "rows": [ { "engine": "InnoDB" @@ -374,9 +336,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra, CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_certificate_field_keys" - ], + "bindings": ["snapshot_certificate_field_keys"], "rows": [ { "name": "snapshotUserId", @@ -418,9 +378,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", - "bindings": [ - "snapshot_certificate_field_keys" - ], + "bindings": ["snapshot_certificate_field_keys"], "rows": [ { "name": "PRIMARY", @@ -489,9 +447,7 @@ }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", - "bindings": [ - "snapshot_certificate_index_progress" - ], + "bindings": ["snapshot_certificate_index_progress"], "rows": [ { "engine": "InnoDB" @@ -500,9 +456,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, EXTRA AS extra, CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_certificate_index_progress" - ], + "bindings": ["snapshot_certificate_index_progress"], "rows": [ { "name": "snapshotTableId", @@ -553,9 +507,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX", - "bindings": [ - "snapshot_certificate_index_progress" - ], + "bindings": ["snapshot_certificate_index_progress"], "rows": [ { "name": "PRIMARY", @@ -568,9 +520,7 @@ }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "proven_txs" - ], + "bindings": ["proven_txs"], "rows": [ { "engine": "InnoDB" @@ -579,16 +529,12 @@ }, { "sql": "SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "proven_txs" - ], + "bindings": ["proven_txs"], "rows": [] }, { "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "proven_txs" - ], + "bindings": ["proven_txs"], "rows": [ { "name": "blockHash", @@ -674,9 +620,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "proven_txs" - ], + "bindings": ["proven_txs"], "rows": [ { "name": "PRIMARY", @@ -703,9 +647,7 @@ }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "proven_tx_reqs" - ], + "bindings": ["proven_tx_reqs"], "rows": [ { "engine": "InnoDB" @@ -714,9 +656,7 @@ }, { "sql": "SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "proven_tx_reqs" - ], + "bindings": ["proven_tx_reqs"], "rows": [ { "updateRule": "NO ACTION", @@ -726,9 +666,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "proven_tx_reqs" - ], + "bindings": ["proven_tx_reqs"], "rows": [ { "name": "attempts", @@ -854,9 +792,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "proven_tx_reqs" - ], + "bindings": ["proven_tx_reqs"], "rows": [ { "name": "PRIMARY", @@ -904,9 +840,7 @@ }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "transactions" - ], + "bindings": ["transactions"], "rows": [ { "engine": "InnoDB" @@ -915,9 +849,7 @@ }, { "sql": "SELECT UPDATE_RULE AS updateRule,DELETE_RULE AS deleteRule FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "transactions" - ], + "bindings": ["transactions"], "rows": [ { "updateRule": "NO ACTION", @@ -931,9 +863,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,EXTRA AS extra,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "transactions" - ], + "bindings": ["transactions"], "rows": [ { "name": "created_at", @@ -1163,9 +1093,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "transactions" - ], + "bindings": ["transactions"], "rows": [ { "name": "idx_transactions_nosend_expiry", @@ -1262,15 +1190,11 @@ }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_global_guards" - ] + "bindings": ["snapshot_global_guards"] }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_global_guards" - ], + "bindings": ["snapshot_global_guards"], "rows": [ { "engine": "InnoDB" @@ -1279,9 +1203,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_global_guards" - ], + "bindings": ["snapshot_global_guards"], "rows": [ { "name": "proofId", @@ -1301,9 +1223,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "snapshot_global_guards" - ], + "bindings": ["snapshot_global_guards"], "rows": [ { "name": "PRIMARY", @@ -1316,15 +1236,11 @@ }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_global_keys" - ] + "bindings": ["snapshot_global_keys"] }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_global_keys" - ], + "bindings": ["snapshot_global_keys"], "rows": [ { "engine": "InnoDB" @@ -1333,9 +1249,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_global_keys" - ], + "bindings": ["snapshot_global_keys"], "rows": [ { "name": "tableId", @@ -1376,9 +1290,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "snapshot_global_keys" - ], + "bindings": ["snapshot_global_keys"], "rows": [ { "name": "PRIMARY", @@ -1454,15 +1366,11 @@ }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_global_edges" - ] + "bindings": ["snapshot_global_edges"] }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_global_edges" - ], + "bindings": ["snapshot_global_edges"], "rows": [ { "engine": "InnoDB" @@ -1471,9 +1379,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_global_edges" - ], + "bindings": ["snapshot_global_edges"], "rows": [ { "name": "transactionId", @@ -1514,9 +1420,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "snapshot_global_edges" - ], + "bindings": ["snapshot_global_edges"], "rows": [ { "name": "PRIMARY", @@ -1564,15 +1468,11 @@ }, { "sql": "select * from information_schema.tables where table_name = ? and table_schema = database()", - "bindings": [ - "snapshot_global_index_progress" - ] + "bindings": ["snapshot_global_index_progress"] }, { "sql": "SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_global_index_progress" - ], + "bindings": ["snapshot_global_index_progress"], "rows": [ { "engine": "InnoDB" @@ -1581,9 +1481,7 @@ }, { "sql": "SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "snapshot_global_index_progress" - ], + "bindings": ["snapshot_global_index_progress"], "rows": [ { "name": "id", @@ -1610,9 +1508,7 @@ }, { "sql": "SELECT INDEX_NAME AS name, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique, COLLATION AS direction, SUB_PART AS prefix FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "snapshot_global_index_progress" - ], + "bindings": ["snapshot_global_index_progress"], "rows": [ { "name": "PRIMARY", @@ -3820,6 +3716,27 @@ "timing": "AFTER", "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END" }, + { + "name": "snapshot_profile_3_delete", + "table": "tx_labels", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; END" + }, + { + "name": "snapshot_profile_3_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txLabelId <=> NEW.txLabelId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END IF; END" + }, + { + "name": "snapshot_profile_3_insert", + "table": "tx_labels", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END" + }, { "name": "snapshot_profile_4_delete", "table": "output_baskets", @@ -3918,6 +3835,20 @@ "timing": "BEFORE", "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END IF; END" }, + { + "name": "snapshot_relation_0_left_delete", + "table": "tx_labels", + "event": "DELETE", + "timing": "AFTER", + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END" + }, + { + "name": "snapshot_relation_0_left_before_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END IF; END" + }, { "name": "snapshot_relation_0_right_delete", "table": "transactions", @@ -3988,6 +3919,20 @@ "timing": "AFTER", "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" }, + { + "name": "snapshot_relation_0_left_insert", + "table": "tx_labels", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" + }, + { + "name": "snapshot_relation_0_left_after_update", + "table": "tx_labels", + "event": "UPDATE", + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" + }, { "name": "snapshot_relation_0_right_insert", "table": "transactions", @@ -4197,55 +4142,6 @@ "event": "UPDATE", "timing": "AFTER", "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" - }, - { - "name": "snapshot_relation_0_left_before_update", - "table": "tx_labels", - "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END IF; END" - }, - { - "name": "snapshot_profile_3_insert", - "table": "tx_labels", - "event": "INSERT", - "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END" - }, - { - "name": "snapshot_relation_0_left_insert", - "table": "tx_labels", - "event": "INSERT", - "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" - }, - { - "name": "snapshot_profile_3_update", - "table": "tx_labels", - "event": "UPDATE", - "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txLabelId <=> NEW.txLabelId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END IF; END" - }, - { - "name": "snapshot_relation_0_left_after_update", - "table": "tx_labels", - "event": "UPDATE", - "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" - }, - { - "name": "snapshot_profile_3_delete", - "table": "tx_labels", - "event": "DELETE", - "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; END" - }, - { - "name": "snapshot_relation_0_left_delete", - "table": "tx_labels", - "event": "DELETE", - "timing": "AFTER", - "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END" } ] }, @@ -9843,9 +9739,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "transactions" - ], + "bindings": ["transactions"], "rows": [ { "name": "created_at" @@ -9935,9 +9829,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "outputs" - ], + "bindings": ["outputs"], "rows": [ { "name": "created_at" @@ -10018,9 +9910,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "certificates" - ], + "bindings": ["certificates"], "rows": [ { "name": "created_at" @@ -10062,9 +9952,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "tx_labels" - ], + "bindings": ["tx_labels"], "rows": [ { "name": "created_at" @@ -10088,9 +9976,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "output_baskets" - ], + "bindings": ["output_baskets"], "rows": [ { "name": "created_at" @@ -10120,9 +10006,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "output_tags" - ], + "bindings": ["output_tags"], "rows": [ { "name": "created_at" @@ -10146,9 +10030,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "commissions" - ], + "bindings": ["commissions"], "rows": [ { "name": "created_at" @@ -10181,9 +10063,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "sync_states" - ], + "bindings": ["sync_states"], "rows": [ { "name": "created_at" @@ -10231,9 +10111,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "proven_txs" - ], + "bindings": ["proven_txs"], "rows": [ { "name": "created_at" @@ -10269,9 +10147,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "proven_tx_reqs" - ], + "bindings": ["proven_tx_reqs"], "rows": [ { "name": "created_at" @@ -10322,9 +10198,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "tx_labels_map" - ], + "bindings": ["tx_labels_map"], "rows": [ { "name": "created_at" @@ -10345,9 +10219,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "output_tags_map" - ], + "bindings": ["output_tags_map"], "rows": [ { "name": "created_at" @@ -10368,9 +10240,7 @@ }, { "sql": "SELECT COLUMN_NAME name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION", - "bindings": [ - "certificate_fields" - ], + "bindings": ["certificate_fields"], "rows": [ { "name": "created_at" @@ -10641,9 +10511,7 @@ }, { "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_journal_generation" - ], + "bindings": ["snapshot_journal_generation"], "rows": [ { "engine": "InnoDB", @@ -10656,9 +10524,7 @@ }, { "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", - "bindings": [ - "snapshot_journal_generation" - ], + "bindings": ["snapshot_journal_generation"], "rows": [ { "name": "id", @@ -10744,9 +10610,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 2", - "bindings": [ - "snapshot_journal_generation" - ], + "bindings": ["snapshot_journal_generation"], "rows": [ { "name": "PRIMARY", @@ -10761,9 +10625,7 @@ }, { "sql": "SELECT CONSTRAINT_NAME name,CONSTRAINT_TYPE type,ENFORCED enforced FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? LIMIT 2", - "bindings": [ - "snapshot_journal_generation" - ], + "bindings": ["snapshot_journal_generation"], "rows": [ { "name": "PRIMARY", @@ -10774,16 +10636,12 @@ }, { "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", - "bindings": [ - "snapshot_journal_generation" - ], + "bindings": ["snapshot_journal_generation"], "rows": [] }, { "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_journal_clock" - ], + "bindings": ["snapshot_journal_clock"], "rows": [ { "engine": "InnoDB", @@ -10791,15 +10649,13 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" } ] }, { "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", - "bindings": [ - "snapshot_journal_clock" - ], + "bindings": ["snapshot_journal_clock"], "rows": [ { "name": "id", @@ -10825,9 +10681,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", - "bindings": [ - "snapshot_journal_clock" - ], + "bindings": ["snapshot_journal_clock"], "rows": [ { "name": "PRIMARY", @@ -10844,9 +10698,7 @@ }, { "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", - "bindings": [ - "snapshot_journal_clock" - ], + "bindings": ["snapshot_journal_clock"], "rows": [ { "name": "PRIMARY", @@ -10864,30 +10716,22 @@ }, { "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", - "bindings": [ - "snapshot_journal_clock" - ], + "bindings": ["snapshot_journal_clock"], "rows": [] }, { "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", - "bindings": [ - "snapshot_journal_clock" - ], + "bindings": ["snapshot_journal_clock"], "rows": [] }, { "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", - "bindings": [ - "snapshot_journal_clock" - ], + "bindings": ["snapshot_journal_clock"], "rows": [] }, { "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_journal_events" - ], + "bindings": ["snapshot_journal_events"], "rows": [ { "engine": "InnoDB", @@ -10895,15 +10739,13 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" } ] }, { "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", - "bindings": [ - "snapshot_journal_events" - ], + "bindings": ["snapshot_journal_events"], "rows": [ { "name": "revision", @@ -10919,9 +10761,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", - "bindings": [ - "snapshot_journal_events" - ], + "bindings": ["snapshot_journal_events"], "rows": [ { "name": "PRIMARY", @@ -10938,9 +10778,7 @@ }, { "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", - "bindings": [ - "snapshot_journal_events" - ], + "bindings": ["snapshot_journal_events"], "rows": [ { "name": "PRIMARY", @@ -10952,30 +10790,22 @@ }, { "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", - "bindings": [ - "snapshot_journal_events" - ], + "bindings": ["snapshot_journal_events"], "rows": [] }, { "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", - "bindings": [ - "snapshot_journal_events" - ], + "bindings": ["snapshot_journal_events"], "rows": [] }, { "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", - "bindings": [ - "snapshot_journal_events" - ], + "bindings": ["snapshot_journal_events"], "rows": [] }, { "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_journal_invalid" - ], + "bindings": ["snapshot_journal_invalid"], "rows": [ { "engine": "InnoDB", @@ -10983,15 +10813,13 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" } ] }, { "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", - "bindings": [ - "snapshot_journal_invalid" - ], + "bindings": ["snapshot_journal_invalid"], "rows": [ { "name": "id", @@ -11017,9 +10845,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", - "bindings": [ - "snapshot_journal_invalid" - ], + "bindings": ["snapshot_journal_invalid"], "rows": [ { "name": "PRIMARY", @@ -11036,9 +10862,7 @@ }, { "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", - "bindings": [ - "snapshot_journal_invalid" - ], + "bindings": ["snapshot_journal_invalid"], "rows": [ { "name": "PRIMARY", @@ -11062,30 +10886,22 @@ }, { "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", - "bindings": [ - "snapshot_journal_invalid" - ], + "bindings": ["snapshot_journal_invalid"], "rows": [] }, { "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", - "bindings": [ - "snapshot_journal_invalid" - ], + "bindings": ["snapshot_journal_invalid"], "rows": [] }, { "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", - "bindings": [ - "snapshot_journal_invalid" - ], + "bindings": ["snapshot_journal_invalid"], "rows": [] }, { "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_journal_physical" - ], + "bindings": ["snapshot_journal_physical"], "rows": [ { "engine": "InnoDB", @@ -11093,15 +10909,13 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" } ] }, { "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", - "bindings": [ - "snapshot_journal_physical" - ], + "bindings": ["snapshot_journal_physical"], "rows": [ { "name": "tableId", @@ -11177,9 +10991,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", - "bindings": [ - "snapshot_journal_physical" - ], + "bindings": ["snapshot_journal_physical"], "rows": [ { "name": "PRIMARY", @@ -11284,9 +11096,7 @@ }, { "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", - "bindings": [ - "snapshot_journal_physical" - ], + "bindings": ["snapshot_journal_physical"], "rows": [ { "name": "PRIMARY", @@ -11298,30 +11108,22 @@ }, { "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", - "bindings": [ - "snapshot_journal_physical" - ], + "bindings": ["snapshot_journal_physical"], "rows": [] }, { "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", - "bindings": [ - "snapshot_journal_physical" - ], + "bindings": ["snapshot_journal_physical"], "rows": [] }, { "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", - "bindings": [ - "snapshot_journal_physical" - ], + "bindings": ["snapshot_journal_physical"], "rows": [] }, { "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_journal_scope" - ], + "bindings": ["snapshot_journal_scope"], "rows": [ { "engine": "InnoDB", @@ -11329,15 +11131,13 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" } ] }, { "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", - "bindings": [ - "snapshot_journal_scope" - ], + "bindings": ["snapshot_journal_scope"], "rows": [ { "name": "tableId", @@ -11413,9 +11213,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", - "bindings": [ - "snapshot_journal_scope" - ], + "bindings": ["snapshot_journal_scope"], "rows": [ { "name": "PRIMARY", @@ -11542,9 +11340,7 @@ }, { "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", - "bindings": [ - "snapshot_journal_scope" - ], + "bindings": ["snapshot_journal_scope"], "rows": [ { "name": "PRIMARY", @@ -11556,30 +11352,22 @@ }, { "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", - "bindings": [ - "snapshot_journal_scope" - ], + "bindings": ["snapshot_journal_scope"], "rows": [] }, { "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", - "bindings": [ - "snapshot_journal_scope" - ], + "bindings": ["snapshot_journal_scope"], "rows": [] }, { "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", - "bindings": [ - "snapshot_journal_scope" - ], + "bindings": ["snapshot_journal_scope"], "rows": [] }, { "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", - "bindings": [ - "snapshot_journal_bootstrap" - ], + "bindings": ["snapshot_journal_bootstrap"], "rows": [ { "engine": "InnoDB", @@ -11587,15 +11375,13 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c" + "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" } ] }, { "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", - "bindings": [ - "snapshot_journal_bootstrap" - ], + "bindings": ["snapshot_journal_bootstrap"], "rows": [ { "name": "id", @@ -11626,14 +11412,32 @@ "charset": "utf8mb4", "collation": "utf8mb4_bin", "expression": "" + }, + { + "name": "rowLimit", + "type": "int", + "nullable": "YES", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "rowsUsed", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" } ] }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", - "bindings": [ - "snapshot_journal_bootstrap" - ], + "bindings": ["snapshot_journal_bootstrap"], "rows": [ { "name": "PRIMARY", @@ -11650,9 +11454,7 @@ }, { "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", - "bindings": [ - "snapshot_journal_bootstrap" - ], + "bindings": ["snapshot_journal_bootstrap"], "rows": [ { "name": "PRIMARY", @@ -11671,43 +11473,52 @@ "type": "CHECK", "enforced": "YES", "clause": "(`stream` between 0 and 17)" + }, + { + "name": "snapshot_journal_bootstrap_chk_3", + "type": "CHECK", + "enforced": "YES", + "clause": "((`rowLimit` is null) or (`rowLimit` between 0 and 2147483647))" + }, + { + "name": "snapshot_journal_bootstrap_chk_4", + "type": "CHECK", + "enforced": "YES", + "clause": "(`rowsUsed` between 0 and 2147483647)" + }, + { + "name": "snapshot_journal_bootstrap_chk_5", + "type": "CHECK", + "enforced": "YES", + "clause": "((`rowLimit` is null) or (`rowsUsed` <= `rowLimit`))" } ] }, { "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", - "bindings": [ - "snapshot_journal_bootstrap" - ], + "bindings": ["snapshot_journal_bootstrap"], "rows": [] }, { "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", - "bindings": [ - "snapshot_journal_bootstrap" - ], + "bindings": ["snapshot_journal_bootstrap"], "rows": [] }, { "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", - "bindings": [ - "snapshot_journal_bootstrap" - ], + "bindings": ["snapshot_journal_bootstrap"], "rows": [] }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1444, - "snapshot_journal_scope_0_INSERT" - ], + "bindings": [1444, "snapshot_journal_scope_0_INSERT"], "rows": [ { "name": "snapshot_journal_scope_0_INSERT", "table": "snapshot_profile_keys", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11718,17 +11529,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1444, - "snapshot_journal_scope_0_DELETE" - ], + "bindings": [1444, "snapshot_journal_scope_0_DELETE"], "rows": [ { "name": "snapshot_journal_scope_0_DELETE", "table": "snapshot_profile_keys", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11739,17 +11547,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1674, - "snapshot_journal_scope_0_UPDATE" - ], + "bindings": [1674, "snapshot_journal_scope_0_UPDATE"], "rows": [ { "name": "snapshot_journal_scope_0_UPDATE", "table": "snapshot_profile_keys", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11760,17 +11565,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1511, - "snapshot_journal_scope_1_INSERT" - ], + "bindings": [1511, "snapshot_journal_scope_1_INSERT"], "rows": [ { "name": "snapshot_journal_scope_1_INSERT", "table": "snapshot_relation_keys", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11781,17 +11583,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1485, - "snapshot_journal_scope_1_DELETE" - ], + "bindings": [1485, "snapshot_journal_scope_1_DELETE"], "rows": [ { "name": "snapshot_journal_scope_1_DELETE", "table": "snapshot_relation_keys", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11802,17 +11601,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1837, - "snapshot_journal_scope_1_UPDATE" - ], + "bindings": [1837, "snapshot_journal_scope_1_UPDATE"], "rows": [ { "name": "snapshot_journal_scope_1_UPDATE", "table": "snapshot_relation_keys", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11823,17 +11619,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1507, - "snapshot_journal_scope_2_INSERT" - ], + "bindings": [1507, "snapshot_journal_scope_2_INSERT"], "rows": [ { "name": "snapshot_journal_scope_2_INSERT", "table": "snapshot_certificate_field_keys", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11844,17 +11637,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1481, - "snapshot_journal_scope_2_DELETE" - ], + "bindings": [1481, "snapshot_journal_scope_2_DELETE"], "rows": [ { "name": "snapshot_journal_scope_2_DELETE", "table": "snapshot_certificate_field_keys", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11865,17 +11655,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1809, - "snapshot_journal_scope_2_UPDATE" - ], + "bindings": [1809, "snapshot_journal_scope_2_UPDATE"], "rows": [ { "name": "snapshot_journal_scope_2_UPDATE", "table": "snapshot_certificate_field_keys", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11886,17 +11673,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1444, - "snapshot_journal_scope_3_INSERT" - ], + "bindings": [1444, "snapshot_journal_scope_3_INSERT"], "rows": [ { "name": "snapshot_journal_scope_3_INSERT", "table": "snapshot_global_keys", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11907,17 +11691,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1434, - "snapshot_journal_scope_3_DELETE" - ], + "bindings": [1434, "snapshot_journal_scope_3_DELETE"], "rows": [ { "name": "snapshot_journal_scope_3_DELETE", "table": "snapshot_global_keys", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11928,17 +11709,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1706, - "snapshot_journal_scope_3_UPDATE" - ], + "bindings": [1706, "snapshot_journal_scope_3_UPDATE"], "rows": [ { "name": "snapshot_journal_scope_3_UPDATE", "table": "snapshot_global_keys", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11949,17 +11727,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1737, - "snapshot_journal_physical_0_INSERT" - ], + "bindings": [1737, "snapshot_journal_physical_0_INSERT"], "rows": [ { "name": "snapshot_journal_physical_0_INSERT", "table": "transactions", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11970,17 +11745,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 5134, - "snapshot_journal_physical_0_UPDATE" - ], + "bindings": [5134, "snapshot_journal_physical_0_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_0_UPDATE", "table": "transactions", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11991,17 +11763,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1498, - "snapshot_journal_physical_0_DELETE" - ], + "bindings": [1498, "snapshot_journal_physical_0_DELETE"], "rows": [ { "name": "snapshot_journal_physical_0_DELETE", "table": "transactions", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12012,17 +11781,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1722, - "snapshot_journal_physical_1_INSERT" - ], + "bindings": [1722, "snapshot_journal_physical_1_INSERT"], "rows": [ { "name": "snapshot_journal_physical_1_INSERT", "table": "outputs", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12033,17 +11799,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 4592, - "snapshot_journal_physical_1_UPDATE" - ], + "bindings": [4592, "snapshot_journal_physical_1_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_1_UPDATE", "table": "outputs", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12054,17 +11817,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1488, - "snapshot_journal_physical_1_DELETE" - ], + "bindings": [1488, "snapshot_journal_physical_1_DELETE"], "rows": [ { "name": "snapshot_journal_physical_1_DELETE", "table": "outputs", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12075,17 +11835,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1737, - "snapshot_journal_physical_2_INSERT" - ], + "bindings": [1737, "snapshot_journal_physical_2_INSERT"], "rows": [ { "name": "snapshot_journal_physical_2_INSERT", "table": "certificates", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12096,17 +11853,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3564, - "snapshot_journal_physical_2_UPDATE" - ], + "bindings": [3564, "snapshot_journal_physical_2_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_2_UPDATE", "table": "certificates", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12117,17 +11871,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1498, - "snapshot_journal_physical_2_DELETE" - ], + "bindings": [1498, "snapshot_journal_physical_2_DELETE"], "rows": [ { "name": "snapshot_journal_physical_2_DELETE", "table": "certificates", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12138,17 +11889,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1725, - "snapshot_journal_physical_3_INSERT" - ], + "bindings": [1725, "snapshot_journal_physical_3_INSERT"], "rows": [ { "name": "snapshot_journal_physical_3_INSERT", "table": "tx_labels", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12159,17 +11907,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3042, - "snapshot_journal_physical_3_UPDATE" - ], + "bindings": [3042, "snapshot_journal_physical_3_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_3_UPDATE", "table": "tx_labels", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12180,17 +11925,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1490, - "snapshot_journal_physical_3_DELETE" - ], + "bindings": [1490, "snapshot_journal_physical_3_DELETE"], "rows": [ { "name": "snapshot_journal_physical_3_DELETE", "table": "tx_labels", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12201,17 +11943,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1722, - "snapshot_journal_physical_4_INSERT" - ], + "bindings": [1722, "snapshot_journal_physical_4_INSERT"], "rows": [ { "name": "snapshot_journal_physical_4_INSERT", "table": "output_baskets", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12222,17 +11961,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3233, - "snapshot_journal_physical_4_UPDATE" - ], + "bindings": [3233, "snapshot_journal_physical_4_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_4_UPDATE", "table": "output_baskets", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12243,17 +11979,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1488, - "snapshot_journal_physical_4_DELETE" - ], + "bindings": [1488, "snapshot_journal_physical_4_DELETE"], "rows": [ { "name": "snapshot_journal_physical_4_DELETE", "table": "output_baskets", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12264,17 +11997,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1731, - "snapshot_journal_physical_5_INSERT" - ], + "bindings": [1731, "snapshot_journal_physical_5_INSERT"], "rows": [ { "name": "snapshot_journal_physical_5_INSERT", "table": "output_tags", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12285,17 +12015,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3060, - "snapshot_journal_physical_5_UPDATE" - ], + "bindings": [3060, "snapshot_journal_physical_5_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_5_UPDATE", "table": "output_tags", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12306,17 +12033,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1494, - "snapshot_journal_physical_5_DELETE" - ], + "bindings": [1494, "snapshot_journal_physical_5_DELETE"], "rows": [ { "name": "snapshot_journal_physical_5_DELETE", "table": "output_tags", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12327,17 +12051,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1734, - "snapshot_journal_physical_6_INSERT" - ], + "bindings": [1734, "snapshot_journal_physical_6_INSERT"], "rows": [ { "name": "snapshot_journal_physical_6_INSERT", "table": "commissions", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12348,17 +12069,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3330, - "snapshot_journal_physical_6_UPDATE" - ], + "bindings": [3330, "snapshot_journal_physical_6_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_6_UPDATE", "table": "commissions", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12369,17 +12087,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1496, - "snapshot_journal_physical_6_DELETE" - ], + "bindings": [1496, "snapshot_journal_physical_6_DELETE"], "rows": [ { "name": "snapshot_journal_physical_6_DELETE", "table": "commissions", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12390,17 +12105,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1731, - "snapshot_journal_physical_7_INSERT" - ], + "bindings": [1731, "snapshot_journal_physical_7_INSERT"], "rows": [ { "name": "snapshot_journal_physical_7_INSERT", "table": "sync_states", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12411,17 +12123,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3676, - "snapshot_journal_physical_7_UPDATE" - ], + "bindings": [3676, "snapshot_journal_physical_7_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_7_UPDATE", "table": "sync_states", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12432,17 +12141,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1494, - "snapshot_journal_physical_7_DELETE" - ], + "bindings": [1494, "snapshot_journal_physical_7_DELETE"], "rows": [ { "name": "snapshot_journal_physical_7_DELETE", "table": "sync_states", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12453,17 +12159,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1443, - "snapshot_journal_physical_8_INSERT" - ], + "bindings": [1443, "snapshot_journal_physical_8_INSERT"], "rows": [ { "name": "snapshot_journal_physical_8_INSERT", "table": "proven_txs", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12474,17 +12177,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3014, - "snapshot_journal_physical_8_UPDATE" - ], + "bindings": [3014, "snapshot_journal_physical_8_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_8_UPDATE", "table": "proven_txs", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12495,17 +12195,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1444, - "snapshot_journal_physical_8_DELETE" - ], + "bindings": [1444, "snapshot_journal_physical_8_DELETE"], "rows": [ { "name": "snapshot_journal_physical_8_DELETE", "table": "proven_txs", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12516,17 +12213,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1449, - "snapshot_journal_physical_9_INSERT" - ], + "bindings": [1449, "snapshot_journal_physical_9_INSERT"], "rows": [ { "name": "snapshot_journal_physical_9_INSERT", "table": "proven_tx_reqs", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12537,17 +12231,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3439, - "snapshot_journal_physical_9_UPDATE" - ], + "bindings": [3439, "snapshot_journal_physical_9_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_9_UPDATE", "table": "proven_tx_reqs", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12558,17 +12249,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1450, - "snapshot_journal_physical_9_DELETE" - ], + "bindings": [1450, "snapshot_journal_physical_9_DELETE"], "rows": [ { "name": "snapshot_journal_physical_9_DELETE", "table": "proven_tx_reqs", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12579,17 +12267,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1883, - "snapshot_journal_physical_10_INSERT" - ], + "bindings": [1883, "snapshot_journal_physical_10_INSERT"], "rows": [ { "name": "snapshot_journal_physical_10_INSERT", "table": "tx_labels_map", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12600,17 +12285,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3182, - "snapshot_journal_physical_10_UPDATE" - ], + "bindings": [3182, "snapshot_journal_physical_10_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_10_UPDATE", "table": "tx_labels_map", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12621,17 +12303,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1471, - "snapshot_journal_physical_10_DELETE" - ], + "bindings": [1471, "snapshot_journal_physical_10_DELETE"], "rows": [ { "name": "snapshot_journal_physical_10_DELETE", "table": "tx_labels_map", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12642,17 +12321,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1874, - "snapshot_journal_physical_11_INSERT" - ], + "bindings": [1874, "snapshot_journal_physical_11_INSERT"], "rows": [ { "name": "snapshot_journal_physical_11_INSERT", "table": "output_tags_map", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12663,17 +12339,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3149, - "snapshot_journal_physical_11_UPDATE" - ], + "bindings": [3149, "snapshot_journal_physical_11_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_11_UPDATE", "table": "output_tags_map", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12684,17 +12357,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1465, - "snapshot_journal_physical_11_DELETE" - ], + "bindings": [1465, "snapshot_journal_physical_11_DELETE"], "rows": [ { "name": "snapshot_journal_physical_11_DELETE", "table": "output_tags_map", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12705,17 +12375,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1885, - "snapshot_journal_physical_12_INSERT" - ], + "bindings": [1885, "snapshot_journal_physical_12_INSERT"], "rows": [ { "name": "snapshot_journal_physical_12_INSERT", "table": "certificate_fields", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12726,17 +12393,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 3328, - "snapshot_journal_physical_12_UPDATE" - ], + "bindings": [3328, "snapshot_journal_physical_12_UPDATE"], "rows": [ { "name": "snapshot_journal_physical_12_UPDATE", "table": "certificate_fields", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12747,17 +12411,14 @@ }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", - "bindings": [ - 1469, - "snapshot_journal_physical_12_DELETE" - ], + "bindings": [1469, "snapshot_journal_physical_12_DELETE"], "rows": [ { "name": "snapshot_journal_physical_12_DELETE", "table": "certificate_fields", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:c4e903f1-fdd4-4eba-8189-3305defcbf2c */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12768,9 +12429,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "transactions" - ], + "bindings": ["transactions"], "rows": [ { "name": "idx_transactions_nosend_expiry", @@ -12828,9 +12487,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "outputs" - ], + "bindings": ["outputs"], "rows": [ { "name": "idx_outputs_funding_selection", @@ -12932,9 +12589,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "certificates" - ], + "bindings": ["certificates"], "rows": [ { "name": "certificates_userid_type_certifier_serialnumber_unique", @@ -12960,9 +12615,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "tx_labels" - ], + "bindings": ["tx_labels"], "rows": [ { "name": "PRIMARY", @@ -12984,9 +12637,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "output_baskets" - ], + "bindings": ["output_baskets"], "rows": [ { "name": "output_baskets_name_userid_unique", @@ -13008,9 +12659,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "output_tags" - ], + "bindings": ["output_tags"], "rows": [ { "name": "output_tags_tag_userid_unique", @@ -13032,9 +12681,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "commissions" - ], + "bindings": ["commissions"], "rows": [ { "name": "commissions_transactionid_index", @@ -13056,9 +12703,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "sync_states" - ], + "bindings": ["sync_states"], "rows": [ { "name": "PRIMARY", @@ -13084,9 +12729,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "proven_txs" - ], + "bindings": ["proven_txs"], "rows": [ { "name": "PRIMARY", @@ -13104,9 +12747,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "proven_tx_reqs" - ], + "bindings": ["proven_tx_reqs"], "rows": [ { "name": "PRIMARY", @@ -13136,9 +12777,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "tx_labels_map" - ], + "bindings": ["tx_labels_map"], "rows": [ { "name": "idx_tx_labels_map_tx_deleted", @@ -13164,9 +12803,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "output_tags_map" - ], + "bindings": ["output_tags_map"], "rows": [ { "name": "idx_output_tags_map_output_deleted_tag", @@ -13196,9 +12833,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "certificate_fields" - ], + "bindings": ["certificate_fields"], "rows": [ { "name": "certificate_fields_certificateid_foreign", @@ -13220,9 +12855,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "snapshot_profile_keys" - ], + "bindings": ["snapshot_profile_keys"], "rows": [ { "name": "PRIMARY", @@ -13240,9 +12873,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "snapshot_relation_keys" - ], + "bindings": ["snapshot_relation_keys"], "rows": [ { "name": "PRIMARY", @@ -13296,9 +12927,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "snapshot_certificate_field_keys" - ], + "bindings": ["snapshot_certificate_field_keys"], "rows": [ { "name": "PRIMARY", @@ -13340,9 +12969,7 @@ }, { "sql": "SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX", - "bindings": [ - "snapshot_global_keys" - ], + "bindings": ["snapshot_global_keys"], "rows": [ { "name": "PRIMARY", @@ -13386,4 +13013,4 @@ } ] } -] \ No newline at end of file +] diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json index ddf63764d..5687a3c4a 100644 --- a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json @@ -1 +1,10 @@ -{"id":1,"version":1,"epoch":"c4e903f1-fdd4-4eba-8189-3305defcbf2c","source":"166b30f8f3e2c27b0bdc36a22b1aa86514cd245a3ad0ce84f69c215890a3b2ee","plan":"f3c86bd5f1bd18744033dbe85036e53f667caa741613b58cf985ba8e7628881c","ceiling":"9223372036854775807","nextObject":57,"complete":1} \ No newline at end of file +{ + "id": 1, + "version": 1, + "epoch": "3ad6e281-3fd7-4e7a-821a-1646b798c5a9", + "source": "166b30f8f3e2c27b0bdc36a22b1aa86514cd245a3ad0ce84f69c215890a3b2ee", + "plan": "9c9f29b167d81462c2b863dd8bf76927f56fdca4f2e6b207bf6d8e6ffd3df373", + "ceiling": "9223372036854775807", + "nextObject": 57, + "complete": 1 +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs index ec1fd6f57..1d82d18c1 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs @@ -31,7 +31,7 @@ async function isolate(k, isolation) { } async function finish(k) { for (let i = 0; i < 100; i++) { - const page = await copy(k) + const page = await copy(k, 1000000) assert(!page.invalidated) if (page.complete) return await complete(k, ceiling) assert(i < 99) @@ -78,13 +78,20 @@ async function child() { if (name) park('after-' + name) if (q.sql.startsWith('update `snapshot_journal_generation` set `nextObject`')) park('after-ack-' + q.bindings[0]) if (completing && q.sql === 'COMMIT;') park('complete-after-commit') + if (q.sql.startsWith('update `snapshot_journal_bootstrap` set `rowLimit`')) park('bootstrap-after-budget-bind') + if (q.sql.startsWith('insert into `snapshot_journal_physical`')) park('bootstrap-after-metadata') + if (q.sql.startsWith('update `snapshot_journal_bootstrap` set `cursor`')) park('bootstrap-after-progress') }) try { await isolate(k, isolation) await install(k, ceiling) + if (boundary.startsWith('bootstrap-')) { + await copy(k, 1000000) + park('bootstrap-after-commit') + } if (boundary.startsWith('complete-')) { for (let i = 0; i < 100; i++) { - const page = await copy(k) + const page = await copy(k, 1000000) if (page.complete) break assert(i < 99) } @@ -136,6 +143,19 @@ async function main() { assert.deepEqual(await install(k, ceiling), created) assert.deepEqual(await read(k, ceiling), created) await assert.rejects(complete(k, ceiling), /Invalid or unowned/) + const outer = await k.transaction(), + independent = open() + try { + await isolate(independent, isolation) + await outer('snapshot_journal_bootstrap').first() + assert.equal((await copy(independent, 1000000)).invalidated, false) + const progress = await independent('snapshot_journal_bootstrap').first() + await assert.rejects(copy(outer, 1000000), /Invalid snapshot journal bootstrap/) + assert.deepEqual(await independent('snapshot_journal_bootstrap').first(), progress) + } finally { + await outer.rollback() + await independent.destroy() + } const completed = await finish(k) await exact(k) assert.equal(completed.complete, true) @@ -201,6 +221,10 @@ async function main() { 'after-ack-7', 'after-snapshot_journal_physical_12_DELETE', 'after-ack-57', + 'bootstrap-after-budget-bind', + 'bootstrap-after-metadata', + 'bootstrap-after-progress', + 'bootstrap-after-commit', 'complete-before-commit', 'complete-after-commit' ] @@ -208,9 +232,31 @@ async function main() { await killAt(boundary, join(directory, isolation.replaceAll(' ', '-') + '-' + boundary), isolation) const saved = (await k.schema.hasTable(intent)) ? await k(intent).first() : undefined if (boundary.startsWith('complete-')) assert.equal(saved.complete, boundary === 'complete-after-commit' ? 1 : 0) + if (boundary.startsWith('bootstrap-')) { + const committed = boundary === 'bootstrap-after-commit', + progress = await k('snapshot_journal_bootstrap').first() + assert.equal(progress.rowsUsed, committed ? baseline.transactions.length : 0) + assert.equal(progress.rowLimit, committed ? 1000000 : null) + assert.equal( + progress.cursor, + committed + ? JSON.stringify([Math.max(...baseline.transactions.map(text => JSON.parse(text).transactionId))]) + : null + ) + } const resumed = await install(k, ceiling) if (saved) assert.equal(resumed.epoch, saved.epoch) await finish(k) + let charged = 0 + for (const table of [ + ...tables, + 'snapshot_profile_keys', + 'snapshot_relation_keys', + 'snapshot_certificate_field_keys', + 'snapshot_global_keys' + ]) + charged += Number((await k(table).count('* AS n').first()).n) + assert.equal((await k('snapshot_journal_bootstrap').first()).rowsUsed, charged) await exact(k) assert.deepEqual(await rows(k), baseline) await k('tx_labels').where('txLabelId', 1).update({ label: 'post-recovery writer' }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs index d769859aa..a97fe3b18 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs @@ -89,7 +89,7 @@ async function isolate(k, isolation) { } async function finish(k) { for (let i = 0; i < 100; i++) { - const page = await copy(k) + const page = await copy(k, 1000000) assert(!page.invalidated) if (page.complete) return await complete(k, ceiling) assert(i < 99) @@ -137,13 +137,20 @@ async function child() { if (name) park('after-' + name) if (q.sql.startsWith('update `snapshot_journal_generation` set `nextObject`')) park('after-ack-' + q.bindings[0]) if (completing && q.sql === 'COMMIT;') park('complete-after-commit') + if (q.sql.startsWith('update `snapshot_journal_bootstrap` set `rowLimit`')) park('bootstrap-after-budget-bind') + if (q.sql.startsWith('insert into `snapshot_journal_physical`')) park('bootstrap-after-metadata') + if (q.sql.startsWith('update `snapshot_journal_bootstrap` set `cursor`')) park('bootstrap-after-progress') }) try { await isolate(k, isolation) await install(k, ceiling) + if (boundary.startsWith('bootstrap-')) { + await copy(k, 1000000) + park('bootstrap-after-commit') + } if (boundary.startsWith('complete-')) { for (let i = 0; i < 100; i++) { - const page = await copy(k) + const page = await copy(k, 1000000) if (page.complete) break assert(i < 99) } @@ -196,6 +203,10 @@ async function main() { 'after-snapshot_journal_clock', 'after-snapshot_journal_scope_0_INSERT', 'after-snapshot_journal_physical_12_DELETE', + 'bootstrap-after-budget-bind', + 'bootstrap-after-metadata', + 'bootstrap-after-progress', + 'bootstrap-after-commit', 'complete-before-commit', 'complete-after-commit' ] @@ -211,9 +222,31 @@ async function main() { const saved = await k(intent).first() assert(saved) if (boundary.startsWith('complete-')) assert.equal(saved.complete, boundary === 'complete-after-commit' ? 1 : 0) + if (boundary.startsWith('bootstrap-')) { + const committed = boundary === 'bootstrap-after-commit', + progress = await k('snapshot_journal_bootstrap').first() + assert.equal(progress.rowsUsed, committed ? baseline.transactions.length : 0) + assert.equal(progress.rowLimit, committed ? 1000000 : null) + assert.equal( + progress.cursor, + committed + ? JSON.stringify([Math.max(...baseline.transactions.map(text => JSON.parse(text).transactionId))]) + : null + ) + } const resumed = await install(k, ceiling) assert.equal(resumed.epoch, saved.epoch) await finish(k) + let charged = 0 + for (const table of [ + ...tables, + 'snapshot_profile_keys', + 'snapshot_relation_keys', + 'snapshot_certificate_field_keys', + 'snapshot_global_keys' + ]) + charged += Number((await k(table).count('* AS n').first()).n) + assert.equal((await k('snapshot_journal_bootstrap').first()).rowsUsed, charged) await exact(k) assert.deepEqual(await rows(k), baseline) await k('tx_labels').where('txLabelId', 1).update({ label: 'post-server-recovery writer' }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs index a152299a3..4330e37b5 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs @@ -22,7 +22,7 @@ const open = filename => pool: { min: 1, max: 1 } }) const finish = async k => { - for (let page = 0; page < 100; page++) if ((await copy(k)).complete) return + for (let page = 0; page < 100; page++) if ((await copy(k, 1000000)).complete) return throw new Error('Bootstrap incomplete') } async function child() { @@ -45,6 +45,9 @@ async function child() { if (sql.startsWith('insert into `snapshot_journal_generation`')) park('install-after-generation') if (sql.startsWith('insert into `snapshot_journal_bootstrap`')) park('install-after-bootstrap') if (sql.startsWith('update `snapshot_journal_generation`')) park('complete-after-state') + if (sql.startsWith('update `snapshot_journal_bootstrap` set `rowlimit`')) park('bootstrap-after-budget-bind') + if (sql.startsWith('insert into `snapshot_journal_physical`')) park('bootstrap-after-metadata') + if (sql.startsWith('update `snapshot_journal_bootstrap` set `cursor`')) park('bootstrap-after-progress') }) k.on('query', q => { if (q.sql.toLowerCase().startsWith('update `snapshot_journal_generation`')) park('complete-before-state') @@ -53,6 +56,9 @@ async function child() { if (boundary.startsWith('install-')) { await install(k, '1000000') park('install-after-commit') + } else if (boundary.startsWith('bootstrap-')) { + await copy(k, 1000000) + park('bootstrap-after-commit') } else { await complete(k) park('complete-after-commit') @@ -89,6 +95,10 @@ async function main() { 'install-after-generation', 'install-after-bootstrap', 'install-after-commit', + 'bootstrap-after-budget-bind', + 'bootstrap-after-metadata', + 'bootstrap-after-progress', + 'bootstrap-after-commit', 'complete-before-state', 'complete-after-state', 'complete-after-commit' @@ -105,6 +115,7 @@ async function main() { await seedArchiveClosure(source, user.userId, other.userId) const original = {} for (const table of tables) original[table] = await k(table) + if (boundary.startsWith('bootstrap-')) await install(k, '1000000') if (boundary.startsWith('complete-')) { await install(k, '1000000') await finish(k) @@ -118,12 +129,31 @@ async function main() { const objects = await k('sqlite_master').whereRaw('lower(substr(name,1,17))=?', ['snapshot_journal_']) const committed = boundary.endsWith('after-commit') if (boundary.startsWith('install-')) assert.equal(objects.length, committed ? 58 : 0) - else assert.equal((await read(k)).complete, committed) + else if (boundary.startsWith('bootstrap-')) { + assert.equal((await read(k)).complete, false) + const progress = await k('snapshot_journal_bootstrap').first() + assert.equal(progress.rowsUsed, committed ? original.transactions.length : 0) + assert.equal(progress.rowLimit, committed ? 1000000 : null) + assert.equal( + progress.cursor, + committed ? JSON.stringify([Math.max(...original.transactions.map(row => row.transactionId))]) : null + ) + } else assert.equal((await read(k)).complete, committed) await install(k, '1000000') await finish(k) await complete(k) assert.equal((await read(k)).complete, true) await exact(k) + let charged = 0 + for (const table of [ + ...tables, + 'snapshot_profile_keys_v2', + 'snapshot_relation_keys_v2', + 'snapshot_certificate_field_keys_v2', + 'snapshot_global_keys_v2' + ]) + charged += Number((await k(table).count('* AS n').first()).n) + assert.equal((await k('snapshot_journal_bootstrap').first()).rowsUsed, charged) for (const table of tables) assert.deepEqual(await k(table), original[table]) assert.deepEqual(await k.raw('PRAGMA foreign_key_check'), []) results.push({ From 05731dd77c35371868a0f47474e4a3a825a966b1 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 08:48:20 -0700 Subject: [PATCH 088/127] feat(wallet): bind durable source receipts to journal generations --- docs/guides/wallet-sync-reliability.md | 29 +- docs/reference/package-api-migrations.md | 74 +- docs/reference/test-quality-governance.md | 8 + governance/mutation-testing/targets.mjs | 4 +- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/README.md | 7 +- .../journal/SnapshotJournal.property.test.ts | 116 +++ .../SnapshotJournalMysqlGeneration.test.ts | 310 ++++-- .../journal/SnapshotJournalMysqlGeneration.ts | 134 ++- .../SnapshotJournalPrerequisites.test.ts | 6 +- .../journal/SnapshotJournalReceipt.test.ts | 249 +++++ .../journal/SnapshotJournalReceipt.ts | 285 ++++++ .../SnapshotJournalReceiptMysql.test.ts | 217 +++++ .../SnapshotJournalSqliteGeneration.test.ts | 191 +++- .../SnapshotJournalSqliteGeneration.ts | 42 +- .../mysql-generation-ddl-fixture.json | 128 +-- .../mysql-generation-metadata-fixture.json | 884 ++++++++++++------ .../mysql-generation-state-fixture.json | 6 +- .../test/storage/runSnapshotJournalMysql.cjs | 13 +- .../test/storage/snapshotJournalMysql.cjs | 49 +- .../snapshotJournalMysqlConnection.cjs | 11 +- .../snapshotJournalMysqlServerCrash.cjs | 11 +- .../storage/snapshotJournalReceiptMysql.cjs | 248 +++++ .../storage/snapshotJournalSqliteCrash.cjs | 23 +- scripts/mutation-partitions.mjs | 3 +- scripts/mutation-partitions.test.mjs | 3 +- scripts/mutation-testing.test.mjs | 4 +- 27 files changed, 2465 insertions(+), 594 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index ce471b9fa..aa8d410e5 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -1068,17 +1068,38 @@ Native recovery fixtures include allowance binding, metadata insertion, progress update and commit boundaries, then independently count the fully bootstrapped streams to detect lost or repeated charges after recovery. +Owned generations take an explicit receipt policy: 1–128 retained records and +a lifetime from 1 millisecond through 30 days. Installation snapshots this policy; +resume/read/completion refuse a mismatch. SQLite owns both tables and their expiry +index atomically; MySQL binds the policy into the durable installation plan and +validates each table's exact typed metadata, ownership and seed after implicit DDL. + +The internal receipt store binds backend, generation, schema/source, storage and +profile identity. Exact retries preserve the original high-water, expiry and +captured floor; a missing receipt refuses continuity even if newer writes have +surpassed its revision. MySQL decisions use current, nonwaiting locking reads, +including after an older transaction view. SQLite mutation transactions require +zero busy timeout. Expired records remain charged until collection commits; each +indexed collection removes at most 64 records. Stored text projections and +capacity probes are bounded. Receipt record/collection process-loss fixtures +check state before and after commit under both MySQL isolation levels. + +A caller must reserve the reader before taking the short writer barrier, pin its +view before recording the proof, and publish only after receipt commit. That +capture controller and atomic floor/tombstone lifecycle are still unfinished. +A durable prefix proof does not reopen a killed database read transaction. + This foundation adds no registered migration, public capability or reader advertisement. Its event-window invalidation is not a complete retention or -resource policy. Full quotas, durable capture/receipt/floor ownership, +resource policy. Full quotas, capture publication and continuity-floor ownership, generation-aware receiver/primary integration, and remaining remote/IndexedDB, streaming and staged-import acceptance remain unfinished. Do not infer full incremental continuity or completed issue #544 from these helpers. -The wallet-snapshot-journal mutation target owns all thirteen complete source -modules in eleven execution parts. Every part retains the complete canonical +The wallet-snapshot-journal mutation target owns all fourteen complete source +modules in twelve execution parts, including the whole receipt module. Every part retains the complete canonical journal tests and fixtures, including one governed property entry for exact -revision/page and generated source-observer schedules. A minimum score of 90%, zero +revision/page, generated source-observer and committed receipt-state schedules. A minimum score of 90%, zero uncovered/invalid mutants, 300 cases with seed 3242026, four workers, runner reuse 8 and 90-minute bounds remain in force. Native ownership, client/server process-loss and broader platform/performance evidence are separate required validation; source helpers diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 49364e9fe..2bd3ee41a 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | The templates dependency repair needs no API migration. This unpublished candidate resolves templates 1.10.3 or later after publication; current deployed consumers retain their published pins. Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `1.10.4` | patch | [API and usage](../packages/helpers/templates.md) | No API migration. Existing 1.10.3 consumers already contain the CommonJS repair; this next candidate retains it and validates the next dependency graph. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. -- Migration: Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. +- Release note: Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index f83711c1b..c0142858d 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -270,6 +270,14 @@ independent critical score and zero-uncovered/invalid gates. Aggregation require the complete disjoint source union and fresh matching provenance; no target, property budget, dependency-selection rule or acceptance threshold changes. +The journal target includes the complete receipt module in its own execution +part. All twelve journal parts retain the common complete tests/fixtures and +the existing governed property suite, including generated receipt histories. +The native receipt fixture is an explicit additional input and a 60-second group +alongside the unchanged owned MySQL generation/server-crash groups. Target and +property counts, 90% aggregate gate, zero invalid/uncovered requirement, 300-case +seeded property settings, worker/reuse limits and existing deadlines are preserved. + PR CI downloads each selected target's complete partition artifacts before canonical verification. The orchestration regression derives the required downloads from the canonical target registry and execution map, preventing a diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 41a039f5e..3ff2f2004 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -501,6 +501,7 @@ export function buildMutationTargets(repositoryRoot) { 'test/storage/snapshotJournalMysqlConnection.cjs', 'test/storage/snapshotJournalMysql.cjs', 'test/storage/snapshotJournalMysqlServerCrash.cjs', + 'test/storage/snapshotJournalReceiptMysql.cjs', 'test/storage/snapshotJournalSqliteCrash.cjs', 'test/storage/runSnapshotJournalMysql.cjs', 'test/storage/snapshotArchiveDocker.cjs' @@ -520,7 +521,8 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts', 'src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', 'src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', - 'src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts' + 'src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts', + 'src/storage/snapshot/journal/SnapshotJournalReceipt.ts' ], ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/journal/*.test.ts'], { maxTestRunnerReuse: 8, diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index cf1468be9..f0423c4de 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records.", - "migration": "Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; quota/receipt/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding." + "summary": "Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 0b112d047..1ee344062 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -54,11 +54,14 @@ unproved cleanup fails qualification. Other wallet shards do not start MySQL. The candidate also contains internal SQL journal primitives for exact revisions, bounded metadata pages, bootstrap with a durable explicit row allowance, source-table observers, and recovery of an -interrupted journal installation. Run `pnpm test:snapshot-journal-crash` for the +interrupted journal installation. Owned generations also bind explicit receipt +capacity/lifetime policy; exact receipts are persisted, read and collected in +bounded caller-owned transactions. Receipts prove a captured prefix and cannot +reopen a lost retained view. The capture controller remains unfinished. Run `pnpm test:snapshot-journal-crash` for the SQLite process-loss fixture and `pnpm test:snapshot-journal-mysql` for the isolated MySQL client/server-process recovery fixtures. These helpers do not register a migration or advertise incremental synchronization. Full retention quotas, -capture receipts, receiver/primary integration and the remaining issue #544 +capture publication, continuity floors, receiver/primary integration and the remaining issue #544 acceptance work are still required; see the [journal foundation](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#internal-journal-foundation-unadvertised). diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts index 03f609e08..7035948b8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts @@ -1005,3 +1005,119 @@ describe('SnapshotJournalSqliteObservers', () => { } }) }) + +// The receipt store's model is independent of SQL affected-row counts, database +// snapshots and the implementation's parser. It exercises actual SQLite commits. +test('bounded receipt schedules preserve exact committed identity, expiry, floor and capacity', async () => { + const Receipt = await import('./SnapshotJournalReceipt') + const ArchiveClock = await import('../archive/SnapshotArchiveSql') + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + let clock = 1000000 + const now = jest.spyOn(ArchiveClock, 'snapshotArchiveDatabaseNow').mockImplementation(async () => clock) + try { + await k.raw('PRAGMA busy_timeout=0') + for (const sql of Receipt.snapshotJournalReceiptDdl(k)) await k.raw(sql) + await k('snapshot_journal_retention').insert({ id: 1, floor: '0', receiptLimit: 3, receiptLifetimeMs: 1000 }) + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + kind: fc.integer({ min: 0, max: 4 }), + key: fc.integer({ min: 1, max: 8 }), + other: fc.boolean(), + rollback: fc.boolean(), + step: fc.integer({ min: 0, max: 80 }) + }), + { minLength: 1, maxLength: 32 } + ), + async commands => { + clock = 1000000 + let floor = 0n + const model = new Map< + string, + { requestId: string; binding: string; highWater: string; floor: string; expiresAt: number } + >() + await k('snapshot_journal_receipts').delete() + await k('snapshot_journal_retention').update({ floor: '0' }) + const binding = { + backend: '11'.repeat(32), + epoch: '12345678-1234-4234-9234-123456789012', + source: '22'.repeat(32), + schema: '33'.repeat(32), + storageIdentity: 'synthetic-receipt-model', + identityKey: '02' + '44'.repeat(32), + userId: 1, + chain: 'test' + } + const rollback = new Error('model rollback') + for (const command of commands) { + const high = 9007199254740992n + BigInt(command.key) + const request = { + requestId: command.key.toString(16).padStart(64, '0'), + highWater: snapshotJournalRevision(high.toString()), + expiresAt: 1000100 + command.key * 20 + } + const owner = { ...binding, userId: command.other ? 2 : 1 } + const digest = Receipt.snapshotJournalReceiptBinding(owner) + const prior = model.get(request.requestId) + if (command.kind === 0) { + const valid = + request.expiresAt > clock && + high >= floor && + (prior === undefined ? model.size < 3 : prior.binding === digest) + const result = { ...request, binding: digest, floor: floor.toString() } + const operation = k.transaction(async t => { + const value = await Receipt.recordSnapshotJournalReceipt(t, owner, request) + if (command.rollback) throw rollback + return value + }) + if (!valid || command.rollback) await expect(operation).rejects.toThrow() + else { + await expect(operation).resolves.toEqual(prior ?? result) + if (prior === undefined) model.set(request.requestId, result) + } + } else if (command.kind === 1) { + const valid = + prior !== undefined && prior.binding === digest && request.expiresAt > clock && high >= floor + const operation = k.transaction(t => Receipt.readSnapshotJournalReceipt(t, owner, request)) + if (valid) await expect(operation).resolves.toEqual(prior) + else await expect(operation).rejects.toThrow() + } else if (command.kind === 2) { + const removed = [...model.values()].filter(row => row.expiresAt <= clock) + const operation = k.transaction(async t => { + expect(await Receipt.collectSnapshotJournalReceipts(t)).toBe(removed.length) + if (command.rollback) throw rollback + }) + if (command.rollback) await expect(operation).rejects.toThrow('model rollback') + else { + await operation + for (const row of removed) model.delete(row.requestId) + } + } else if (command.kind === 3) { + clock += command.step + } else { + const next = high > floor ? high : floor + await k.transaction(async t => { + await t('snapshot_journal_retention').update({ floor: next.toString() }) + if (command.rollback) await t.rollback() + }) + if (!command.rollback) floor = next + } + const rows = await k('snapshot_journal_receipts').select('*').orderBy('requestId') + expect(rows).toEqual([...model.values()].sort((a, b) => a.requestId.localeCompare(b.requestId))) + expect(rows.length).toBeLessThanOrEqual(3) + } + } + ), + propertyParameters + ) + } finally { + now.mockRestore() + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts index 56febd41a..b442c745d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts @@ -9,6 +9,9 @@ import { } from './SnapshotJournalMysqlGeneration' import { readSnapshotJournalMysqlBinding } from './SnapshotJournalMysqlSource' import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' + +const journalReceiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } + jest.mock('./SnapshotJournalMysqlSource', () => ({ readSnapshotJournalMysqlBinding: jest.fn() })) interface Capture { sql: string @@ -56,6 +59,13 @@ async function fixture() { }) await database.raw('CREATE TABLE snapshot_journal_invalid(id INTEGER,reason TEXT)') await database.raw('CREATE TABLE snapshot_journal_events(revision TEXT)') + await database.raw( + 'CREATE TABLE snapshot_journal_retention(id INTEGER,floor TEXT,receiptLimit INTEGER,receiptLifetimeMs BIGINT)' + ) + await database('snapshot_journal_retention').insert({ id: 1, floor: '0', ...journalReceiptPolicy }) + await database.raw( + 'CREATE TABLE snapshot_journal_receipts(requestId TEXT,binding TEXT,highWater TEXT,floor TEXT,expiresAt BIGINT)' + ) const metadata = structuredClone(captured), writes: string[] = [] database.on('query', query => { @@ -78,7 +88,15 @@ async function fixture() { } } const raw = jest.fn((sql: string, values?: unknown[]) => { - if (sql === 'CAST(ceiling AS CHAR) ceiling') return database.raw(sql) + if ( + [ + 'CAST(ceiling AS CHAR) ceiling', + 'CAST(receiptLifetimeMs AS CHAR) receiptLifetimeMs', + 'SUBSTRING(floor,1,20) floor', + '1 AS occupied' + ].includes(sql) + ) + return database.raw(sql) if (sql === 'SELECT VERSION() version') return Promise.resolve([[{ version: '8.4.0' }]]) const name = /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] if (name) @@ -109,6 +127,8 @@ async function fixture() { if (name === 'snapshot_journal_clock') await database(name).insert({ id: 1, ceiling: values![0] }) if (name === 'snapshot_journal_bootstrap') await database(name).insert({ id: 1, stream: 0, cursor: null, rowLimit: null, rowsUsed: 0 }) + if (name === 'snapshot_journal_retention') + await database(name).insert({ id: 1, floor: '0', receiptLimit: values![0], receiptLifetimeMs: values![1] }) available.add(name) fail('after:' + name) return {} @@ -168,7 +188,9 @@ async function fixture() { 'snapshot_journal_clock', 'snapshot_journal_bootstrap', 'snapshot_journal_invalid', - 'snapshot_journal_events' + 'snapshot_journal_events', + 'snapshot_journal_retention', + 'snapshot_journal_receipts' ]) await database(table).delete() available = new Set() @@ -186,17 +208,17 @@ async function fixture() { test('native metadata resumes the complete persisted generation without writes', async () => { const f = await fixture() try { - expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling)).toEqual({ + expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toEqual({ source: nativeState.source, plan: nativeState.plan, ceiling, epoch: nativeState.epoch, - nextObject: 57, + nextObject: 59, complete: true, enabled: true }) - expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling)).toEqual( - await readSnapshotJournalMysqlGeneration(f.k, ceiling) + expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toEqual( + await readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy) ) expect(f.writes).toEqual([]) } finally { @@ -206,13 +228,13 @@ test('native metadata resumes the complete persisted generation without writes', test('lost final object acknowledgement resumes its epoch and advances only the durable intent', async () => { const f = await fixture() try { - await f.database('snapshot_journal_generation').update({ nextObject: 56, complete: 0 }) + await f.database('snapshot_journal_generation').update({ nextObject: 58, complete: 0 }) await f.database('snapshot_journal_bootstrap').update({ stream: 0, rowLimit: null }) f.writes.length = 0 - const resumed = await installSnapshotJournalMysqlGeneration(f.k, ceiling) + const resumed = await installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy) expect(resumed).toMatchObject({ epoch: nativeState.epoch, - nextObject: 57, + nextObject: 59, complete: false, enabled: true }) @@ -226,7 +248,9 @@ test.each(['sqlite3', 'better-sqlite3', 'pg'])('unsupported driver %s performs n const f = await fixture() try { f.k.client.config.client = client - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.raw).not.toHaveBeenCalled() } finally { await f.database.destroy() @@ -236,7 +260,9 @@ test('DDL refuses a caller-owned transaction', async () => { const f = await fixture() try { Object.defineProperty(f.k, 'isTransaction', { value: true }) - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.raw).not.toHaveBeenCalled() } finally { await f.database.destroy() @@ -246,7 +272,7 @@ test('generation reads accept the caller pinned view without DDL', async () => { const f = await fixture() try { Object.defineProperty(f.k, 'isTransaction', { value: true }) - expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toMatchObject({ epoch: nativeState.epoch, complete: true, enabled: true @@ -260,7 +286,9 @@ test('persisted allocator rows cannot establish event continuity', async () => { const f = await fixture() try { await f.database('snapshot_journal_events').insert({ revision: '1' }) - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await f.database.destroy() } @@ -268,7 +296,9 @@ test('persisted allocator rows cannot establish event continuity', async () => { test.each(['0', '01', '9223372036854775808'])('invalid ceiling %s refuses before metadata', async value => { const f = await fixture() try { - await expect(installSnapshotJournalMysqlGeneration(f.k, value as typeof ceiling)).rejects.toThrow() + await expect( + installSnapshotJournalMysqlGeneration(f.k, value as typeof ceiling, journalReceiptPolicy) + ).rejects.toThrow() expect(f.raw).not.toHaveBeenCalled() } finally { await f.database.destroy() @@ -306,7 +336,9 @@ test.each([ const f = await fixture() try { f.rows(fragment as string, 'snapshot_journal_clock')[0][field as string] = value - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.writes).toEqual([]) } finally { await f.database.destroy() @@ -318,7 +350,9 @@ test.each(['TABLE_COMMENT comment', 'ORDINAL_POSITION LIMIT 9', 'SEQ_IN_INDEX LI const f = await fixture() try { f.rows(fragment, 'snapshot_journal_clock').pop() - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await f.database.destroy() } @@ -330,7 +364,9 @@ test.each(['EVENT_OBJECT_TABLE=? LIMIT 1', 'PARTITION_NAME IS NOT NULL LIMIT 1', const f = await fixture() try { f.rows(fragment, 'snapshot_journal_clock').push({ name: 'foreign' }) - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await f.database.destroy() } @@ -351,7 +387,9 @@ test.each([ const f = await fixture() try { f.rows('SUBSTRING(ACTION_STATEMENT,1,?)', 'snapshot_journal_scope_0_INSERT')[0][field] = value - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await f.database.destroy() } @@ -379,7 +417,9 @@ test.each(['unknown', 'view', 'missing-prior', 'duplicate', 'future', 'many'])( })) ) f.writes.length = 0 - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.writes).toEqual([]) } finally { await f.database.destroy() @@ -387,8 +427,8 @@ test.each(['unknown', 'view', 'missing-prior', 'duplicate', 'future', 'many'])( } ) test.each([ + { nextObject: 60 }, { nextObject: 58 }, - { nextObject: 56 }, { source: 'a'.repeat(64) }, { plan: 'a'.repeat(64) }, { ceiling: '1' } @@ -396,7 +436,9 @@ test.each([ const f = await fixture() try { await f.database('snapshot_journal_generation').update(patch) - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await f.database.destroy() } @@ -413,7 +455,9 @@ test.each([ const f = await fixture() try { await f.database('snapshot_journal_bootstrap').update(patch) - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await f.database.destroy() } @@ -424,7 +468,9 @@ test.each(['snapshot_journal_clock', 'snapshot_journal_bootstrap'])( const f = await fixture() try { await f.database(table).delete() - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await f.database.destroy() } @@ -436,7 +482,7 @@ test.each(['capacity-exhausted', 'revision-exhausted', 'key-out-of-range'])( const f = await fixture() try { await f.database('snapshot_journal_invalid').insert({ id: 1, reason }) - expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + expect(await readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toMatchObject({ complete: true, enabled: false }) @@ -452,7 +498,9 @@ test.each([ const f = await fixture() try { await f.database('snapshot_journal_invalid').insert(row) - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await f.database.destroy() } @@ -465,14 +513,14 @@ test('fresh installation uses the independently captured native DDL and atomical const f = await fixture() try { await f.fresh() - const state = await installSnapshotJournalMysqlGeneration(f.k, ceiling) - expect(state).toMatchObject({ nextObject: 57, complete: false, enabled: true }) + const state = await installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy) + expect(state).toMatchObject({ nextObject: 59, complete: false, enabled: true }) expect(state.epoch).not.toBe(nativeState.epoch) expect(await f.database('snapshot_journal_clock')).toEqual([{ id: 1, ceiling }]) expect(await f.database('snapshot_journal_bootstrap')).toEqual([ { id: 1, stream: 0, cursor: null, rowLimit: null, rowsUsed: 0 } ]) - expect(objectNames).toHaveLength(58) + expect(objectNames).toHaveLength(60) } finally { await f.database.destroy() } @@ -482,13 +530,15 @@ test.each(objectNames)('every DDL acknowledgement loss resumes without adopting/ try { await f.fresh() f.fault.phase = 'after:' + name - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('lost reply') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'lost reply' + ) const before = await f.database('snapshot_journal_generation').first() - const resumed = await installSnapshotJournalMysqlGeneration(f.k, ceiling) + const resumed = await installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy) expect(f.fault.fired).toBe(true) expect(resumed).toMatchObject({ epoch: before.epoch, - nextObject: 57, + nextObject: 59, complete: false, enabled: true }) @@ -501,14 +551,16 @@ test.each(objectNames)('every DDL acknowledgement loss resumes without adopting/ await f.database.destroy() } }) -test.each([0, 1, 5, 6, 57])('before DDL boundary %i creates no undocumented object', async index => { +test.each([0, 1, 5, 6, 7, 8, 59])('before DDL boundary %i creates no undocumented object', async index => { const f = await fixture() try { await f.fresh() f.fault.phase = 'before:' + objectNames[index] - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('lost reply') - expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ - nextObject: 57, + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'lost reply' + ) + expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toMatchObject({ + nextObject: 59, complete: false, enabled: true }) @@ -516,15 +568,17 @@ test.each([0, 1, 5, 6, 57])('before DDL boundary %i creates no undocumented obje await f.database.destroy() } }) -test.each([1, 6, 7, 57])('lost progress acknowledgement %i resumes committed state', async position => { +test.each([1, 6, 7, 8, 9, 59])('lost progress acknowledgement %i resumes committed state', async position => { const f = await fixture() try { await f.fresh() f.fault.phase = 'ack:' + position - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('lost reply') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'lost reply' + ) expect((await f.database('snapshot_journal_generation').first()).nextObject).toBe(position) - expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ - nextObject: 57, + expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toMatchObject({ + nextObject: 59, complete: false, enabled: true }) @@ -535,9 +589,9 @@ test.each([1, 6, 7, 57])('lost progress acknowledgement %i resumes committed sta test('completion commits once and can be read in the same retained generation', async () => { const f = await fixture() try { - expect(await completeSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + expect(await completeSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toMatchObject({ epoch: nativeState.epoch, - nextObject: 57, + nextObject: 59, complete: true, enabled: true }) @@ -553,9 +607,11 @@ test.each(['complete:before', 'complete:after'])( try { await f.database('snapshot_journal_generation').update({ complete: 0 }) f.fault.phase = phase - await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('lost reply') + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'lost reply' + ) expect((await f.database('snapshot_journal_generation').first()).complete).toBe(0) - expect(await completeSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ + expect(await completeSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toMatchObject({ complete: true, enabled: true }) @@ -570,7 +626,9 @@ test.each(['incomplete', 'invalidated'])('completion refuses %s progress without await f.database('snapshot_journal_generation').update({ complete: 0 }) if (kind === 'incomplete') await f.database('snapshot_journal_bootstrap').update({ stream: 16 }) else await f.database('snapshot_journal_invalid').insert({ id: 1, reason: 'capacity-exhausted' }) - await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect((await f.database('snapshot_journal_generation').first()).complete).toBe(0) } finally { await f.database.destroy() @@ -591,7 +649,9 @@ test.each(['missing row', 'extra row', 'missing field', 'extra field', 'numeric if (kind === 'extra field') rows[0].unexpected = 'unknown' if (kind === 'numeric field') rows[0].charset = 1 if (kind === 'oversized field') rows[0].sqlMode = 'x'.repeat(4097) - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.writes).toEqual([]) } finally { await f.database.destroy() @@ -609,7 +669,9 @@ test.each(['malformed trigger', 'missing trigger', 'duplicate trigger'])( if (kind === 'missing trigger') definitions.pop() if (kind === 'duplicate trigger') definitions[1] = definitions[0] spy = jest.spyOn(observers, 'snapshotJournalMysqlObserverSql').mockResolvedValue(definitions) - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.writes).toEqual([]) } finally { spy?.mockRestore() @@ -620,9 +682,9 @@ test.each(['malformed trigger', 'missing trigger', 'duplicate trigger'])( test('zero journal capacity refuses before metadata reads or writes', async () => { const f = await fixture() try { - await expect(installSnapshotJournalMysqlGeneration(f.k, snapshotJournalRevision('0'))).rejects.toThrow( - 'Invalid or unowned' - ) + await expect( + installSnapshotJournalMysqlGeneration(f.k, snapshotJournalRevision('0'), journalReceiptPolicy) + ).rejects.toThrow('Invalid or unowned') expect(f.raw).not.toHaveBeenCalled() expect(f.writes).toEqual([]) } finally { @@ -634,7 +696,9 @@ test('installation refuses a changed source binding before returning generation const f = await fixture() try { readBinding.mockResolvedValueOnce(String(nativeState.source)).mockResolvedValueOnce('f'.repeat(64)) - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.writes).toEqual([]) } finally { await f.database.destroy() @@ -645,11 +709,13 @@ test('lost update ownership cannot acknowledge an installation object', async () try { await f.fresh() f.fault.zeroNext = true - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect((await f.database('snapshot_journal_generation').first()).nextObject).toBe(0) f.fault.zeroNext = false - expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling)).toMatchObject({ - nextObject: 57, + expect(await installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).toMatchObject({ + nextObject: 59, complete: false }) } finally { @@ -661,7 +727,9 @@ test('lost update ownership cannot publish completion', async () => { try { await f.database('snapshot_journal_generation').update({ complete: 0 }) f.fault.zeroComplete = true - await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect((await f.database('snapshot_journal_generation').first()).complete).toBe(0) } finally { await f.database.destroy() @@ -679,11 +747,13 @@ test.each(['clock missing', 'epoch changed', 'progress changed', 'bootstrap miss await t('snapshot_journal_generation').update({ epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }) - if (kind === 'progress changed') await t('snapshot_journal_generation').update({ nextObject: 56 }) + if (kind === 'progress changed') await t('snapshot_journal_generation').update({ nextObject: 58 }) if (kind === 'bootstrap missing') await t('snapshot_journal_bootstrap').delete() if (kind === 'invalidated') await t('snapshot_journal_invalid').insert({ id: 1, reason: 'capacity-exhausted' }) } - await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect((await f.database('snapshot_journal_generation').first()).complete).toBe(0) expect(await f.database('snapshot_journal_clock')).toEqual([{ id: 1, ceiling }]) expect(await f.database('snapshot_journal_invalid')).toEqual([]) @@ -695,21 +765,25 @@ test.each(['clock missing', 'epoch changed', 'progress changed', 'bootstrap miss test('installation refuses a partial bootstrap cursor before resuming DDL', async () => { const f = await fixture() try { - await f.database('snapshot_journal_generation').update({ nextObject: 56, complete: 0 }) + await f.database('snapshot_journal_generation').update({ nextObject: 58, complete: 0 }) await f.database('snapshot_journal_bootstrap').update({ stream: 1 }) f.writes.length = 0 - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.writes).toEqual([]) } finally { await f.database.destroy() } }) -test.each([58, 56])('installation refuses invalid complete next-object position %i', async nextObject => { +test.each([60, 58])('installation refuses invalid complete next-object position %i', async nextObject => { const f = await fixture() try { await f.database('snapshot_journal_generation').update({ nextObject }) f.writes.length = 0 - await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.writes).toEqual([]) } finally { await f.database.destroy() @@ -723,7 +797,9 @@ test('generation requires its intent in the reserved-object inventory', async () rows.findIndex(row => row.name === 'snapshot_journal_generation'), 1 ) - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(f.writes).toEqual([]) } finally { await f.database.destroy() @@ -741,10 +817,120 @@ test('final state rechecks a clock response after object validation', async () = } f.database.on('query-response', listener) try { - await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(reads).toBe(2) } finally { f.database.off('query-response', listener) await f.database.destroy() } }) + +test('receipt policy is immutable across installation, reads and completion', async () => { + const f = await fixture() + try { + for (const policy of [ + { ...journalReceiptPolicy, receiptLimit: 127 }, + { ...journalReceiptPolicy, receiptLifetimeMs: 1000 } + ]) { + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, policy)).rejects.toThrow() + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, policy)).rejects.toThrow() + await expect(completeSnapshotJournalMysqlGeneration(f.k, ceiling, policy)).rejects.toThrow() + } + expect(f.writes).toEqual([]) + expect(await f.database('snapshot_journal_retention')).toEqual([{ id: 1, floor: '0', ...journalReceiptPolicy }]) + } finally { + await f.database.destroy() + } +}) + +test('opening snapshots the policy before an asynchronous source read', async () => { + const f = await fixture() + try { + const policy = { ...journalReceiptPolicy } + readBinding.mockImplementationOnce(async () => { + policy.receiptLimit = 1 + policy.receiptLifetimeMs = 1 + return String(nativeState.source) + }) + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, policy)).resolves.toMatchObject({ complete: true }) + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) + +test.each([ + null, + undefined, + [], + {}, + { receiptLimit: 0, receiptLifetimeMs: 1 }, + { receiptLimit: 129, receiptLifetimeMs: 1 }, + { receiptLimit: 1.5, receiptLifetimeMs: 1 }, + { receiptLimit: 1, receiptLifetimeMs: 0 }, + { receiptLimit: 1, receiptLifetimeMs: 2592000001 } +])('invalid receipt policy %o refuses before SQL', async policy => { + const f = await fixture() + try { + f.raw.mockClear() + await expect( + installSnapshotJournalMysqlGeneration(f.k, ceiling, policy as typeof journalReceiptPolicy) + ).rejects.toThrow() + await expect( + readSnapshotJournalMysqlGeneration(f.k, ceiling, policy as typeof journalReceiptPolicy) + ).rejects.toThrow() + await expect( + completeSnapshotJournalMysqlGeneration(f.k, ceiling, policy as typeof journalReceiptPolicy) + ).rejects.toThrow() + expect(f.raw).not.toHaveBeenCalled() + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) + +test.each(['missing', 'duplicate', 'limit', 'lifetime', 'floor', 'capacity'])( + 'refuses invalid retained receipt state: %s', + async kind => { + const f = await fixture() + try { + if (kind === 'missing') await f.database('snapshot_journal_retention').delete() + if (kind === 'duplicate') + await f.database('snapshot_journal_retention').insert({ id: 2, floor: '0', ...journalReceiptPolicy }) + if (kind === 'limit') await f.database('snapshot_journal_retention').update({ receiptLimit: 127 }) + if (kind === 'lifetime') await f.database('snapshot_journal_retention').update({ receiptLifetimeMs: 1000 }) + if (kind === 'floor') await f.database('snapshot_journal_retention').update({ floor: '01' }) + if (kind === 'capacity') + await f.database('snapshot_journal_receipts').insert( + Array.from({ length: 129 }, (_, n) => ({ + requestId: String(n), + binding: 'b', + highWater: '1', + floor: '0', + expiresAt: 1 + })) + ) + f.writes.length = 0 + await expect(readSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow() + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } + } +) + +test('partially installed retention cannot claim a progressed floor', async () => { + const f = await fixture() + try { + await f.database('snapshot_journal_generation').update({ nextObject: 58, complete: 0 }) + await f.database('snapshot_journal_bootstrap').update({ stream: 0, cursor: null, rowLimit: null, rowsUsed: 0 }) + await f.database('snapshot_journal_retention').update({ floor: '1' }) + f.writes.length = 0 + await expect(installSnapshotJournalMysqlGeneration(f.k, ceiling, journalReceiptPolicy)).rejects.toThrow() + expect(f.writes).toEqual([]) + } finally { + await f.database.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts index 03eff12e0..207de76f2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts @@ -6,7 +6,16 @@ import { SNAPSHOT_JOURNAL_MYSQL_CLOCK_DDL } from './SnapshotJournalMysqlClock' import { SNAPSHOT_JOURNAL_MYSQL_METADATA_DDL, snapshotJournalMysqlObserverSql } from './SnapshotJournalMysqlObservers' import { SNAPSHOT_JOURNAL_BOOTSTRAP_DDL, validSnapshotJournalBootstrapBudget } from './SnapshotJournalBootstrap' import { readSnapshotJournalMysqlBinding } from './SnapshotJournalMysqlSource' -import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' +import { + compareSnapshotJournalRevisions, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' +import { + snapshotJournalReceiptDdl, + snapshotJournalReceiptPolicy, + type SnapshotJournalReceiptPolicy +} from './SnapshotJournalReceipt' import { createSnapshotJournalMysqlIntent, readSnapshotJournalMysqlIntent, @@ -32,7 +41,8 @@ interface Table { columns: Column[] indexes: Index[] checks: string[] - seed?: 'clock' | 'bootstrap' + seed?: 'clock' | 'bootstrap' | 'retention' + charset?: 'ascii' } interface Trigger { name: string @@ -53,6 +63,7 @@ interface Plan { binding: SnapshotJournalMysqlBinding objects: ObjectDefinition[] context: Context + receiptPolicy: SnapshotJournalReceiptPolicy } export interface SnapshotJournalMysqlGeneration extends SnapshotJournalMysqlIntent { enabled: boolean @@ -72,12 +83,14 @@ const physicalKey = ['tableId', 'id1', 'id2', 'exactText'] const physicalColumns = [integer('tableId'), big('id1'), big('id2'), column('exactText', 'varbinary(400)')] const metadataColumns = [big('revision'), column('present', 'tinyint')] const tail = ' ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC' +const objectCount = 59 -function tables(): Table[] { +function tables(k: Knex): Table[] { const ddl = [ ...SNAPSHOT_JOURNAL_MYSQL_CLOCK_DDL, ...SNAPSHOT_JOURNAL_MYSQL_METADATA_DDL, - SNAPSHOT_JOURNAL_BOOTSTRAP_DDL + SNAPSHOT_JOURNAL_BOOTSTRAP_DDL, + ...snapshotJournalReceiptDdl(k) ] const specs: Omit[] = [ { @@ -146,12 +159,50 @@ function tables(): Table[] { '((`rowLimit` is null) or (`rowsUsed` <= `rowLimit`))' ], seed: 'bootstrap' + }, + { + name: 'snapshot_journal_retention', + columns: [ + integer('id'), + column('floor', 'varchar(19)'), + integer('receiptLimit'), + column('receiptLifetimeMs', 'bigint') + ], + indexes: [primary('id')], + checks: ['(`id` = 1)', '(`receiptLimit` between 1 and 128)', '(`receiptLifetimeMs` between 1 and 2592000000)'], + seed: 'retention', + charset: 'ascii' + }, + { + name: 'snapshot_journal_receipts', + columns: [ + column('requestId', 'varchar(64)'), + column('binding', 'varchar(64)'), + column('highWater', 'varchar(19)'), + column('floor', 'varchar(19)'), + column('expiresAt', 'bigint') + ], + indexes: [ + primary('requestId'), + { name: 'snapshot_journal_receipts_expiry', columns: ['expiresAt', 'requestId'], unique: false } + ], + checks: ['(`expiresAt` between 1 and 9007199254740991)'], + charset: 'ascii' } ] - return specs.map((spec, i) => ({ ...spec, sql: ddl[i].replace(/ ENGINE=InnoDB$/, '') + tail })) + return specs.map((spec, i) => ({ + ...spec, + sql: spec.charset === 'ascii' ? ddl[i] : ddl[i].replace(/ ENGINE=InnoDB$/, '') + tail + })) } -async function plan(k: Knex, ceiling: SnapshotJournalRevision, config?: Knex.MigratorConfig): Promise { +async function plan( + k: Knex, + ceiling: SnapshotJournalRevision, + receiptPolicy: SnapshotJournalReceiptPolicy, + config?: Knex.MigratorConfig +): Promise { + const policy = snapshotJournalReceiptPolicy(receiptPolicy) client(k) if (snapshotJournalRevision(ceiling) === '0') return invalid() const source = await readSnapshotJournalMysqlBinding(k, config) @@ -178,16 +229,16 @@ async function plan(k: Knex, ceiling: SnapshotJournalRevision, config?: Knex.Mig return { name: match[1], event: match[2], table: match[3], body: match[4], sql } }) const objects: ObjectDefinition[] = [ - ...tables().map(definition => ({ type: 'table' as const, definition })), + ...tables(k).map(definition => ({ type: 'table' as const, definition })), ...triggers.map(definition => ({ type: 'trigger' as const, definition })) ] - if (objects.length !== 57 || new Set(objects.map(object => object.definition.name)).size !== objects.length) + if (objects.length !== objectCount || new Set(objects.map(object => object.definition.name)).size !== objects.length) return invalid() const digest = createHash('sha256') .update('snapshot-journal-mysql-plan-v1\n') - .update(JSON.stringify([objects, context])) + .update(JSON.stringify([objects, context, policy])) .digest('hex') - return { binding: { source, ceiling, plan: digest }, objects, context } + return { binding: { source, ceiling, plan: digest }, objects, context, receiptPolicy: policy } } async function reserved(k: Knex): Promise> { @@ -197,7 +248,7 @@ async function reserved(k: Knex): Promise> const [triggers]: Array> = await k.raw( "SELECT TRIGGER_NAME name,'TRIGGER' type FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND LOWER(LEFT(TRIGGER_NAME,17))='snapshot_journal_' ORDER BY TRIGGER_NAME LIMIT 130" ) - if (tables.length + triggers.length > 58) return invalid() + if (tables.length + triggers.length > objectCount + 1) return invalid() return [...tables, ...triggers] } const owner = (epoch: string): string => 'snapshot-journal-owner:' + epoch @@ -206,6 +257,7 @@ function triggerBody(trigger: Trigger, epoch: string): string { } async function validateTable(k: Knex, table: Table, epoch: string): Promise { + const charset = table.charset ?? 'utf8mb4' const [actual]: Array< Array<{ engine: string @@ -223,7 +275,7 @@ async function validateTable(k: Knex, table: Table, epoch: string): Promise { if (object.type === 'table') { await validateTable(k, object.definition, state.epoch) @@ -322,7 +375,7 @@ async function validateObject( const clocks = await k('snapshot_journal_clock').select('id', k.raw('CAST(ceiling AS CHAR) ceiling')).limit(2) if (clocks.length !== 1 || clocks[0].id !== 1 || clocks[0].ceiling !== state.ceiling) return invalid() } - if (object.definition.seed === 'bootstrap' && state.nextObject < 57) { + if (object.definition.seed === 'bootstrap' && state.nextObject < objectCount) { const progress = await k('snapshot_journal_bootstrap').select('*').limit(2) if ( progress.length !== 1 || @@ -334,6 +387,29 @@ async function validateObject( ) return invalid() } + if (object.definition.seed === 'retention') { + const rows = await k('snapshot_journal_retention') + .select( + 'id', + 'receiptLimit', + k.raw('CAST(receiptLifetimeMs AS CHAR) receiptLifetimeMs'), + k.raw('SUBSTRING(floor,1,20) floor') + ) + .limit(2) + if ( + rows.length !== 1 || + rows[0].id !== 1 || + rows[0].receiptLimit !== policy.receiptLimit || + rows[0].receiptLifetimeMs !== String(policy.receiptLifetimeMs) + ) + return invalid() + const floor = snapshotJournalRevision(rows[0].floor) + if ( + compareSnapshotJournalRevisions(floor, state.ceiling) > 0 || + (state.nextObject < objectCount && floor !== '0') + ) + return invalid() + } return } const definition = object.definition, @@ -389,7 +465,7 @@ async function validateObjects(k: Knex, p: Plan, state: SnapshotJournalMysqlInte const found = actual.filter(row => row.name === object.definition.name) if ((i < state.nextObject && found.length !== 1) || found.length > 1) return invalid() if (found.length === 1) { - await validateObject(k, object, state, p.context) + await validateObject(k, object, state, p.context, p.receiptPolicy) if (i === state.nextObject) currentExists = true } }) @@ -402,6 +478,10 @@ async function validateState( state: SnapshotJournalMysqlIntent ): Promise { if (await k('snapshot_journal_events').first('revision')) return invalid() + const receipts = await k('snapshot_journal_receipts') + .select(k.raw('1 AS occupied')) + .limit(p.receiptPolicy.receiptLimit + 1) + if (receipts.length > p.receiptPolicy.receiptLimit) return invalid() const clocks = await k('snapshot_journal_clock').select('id', k.raw('CAST(ceiling AS CHAR) ceiling')).limit(2) if (clocks.length !== 1 || clocks[0].id !== 1 || clocks[0].ceiling !== p.binding.ceiling) return invalid() const progress = await k('snapshot_journal_bootstrap').select('*').limit(2) @@ -438,10 +518,11 @@ async function validateState( export async function installSnapshotJournalMysqlGeneration( k: Knex, ceiling: SnapshotJournalRevision, + receiptPolicy: SnapshotJournalReceiptPolicy, config?: Knex.MigratorConfig ): Promise { if (k.isTransaction) return invalid() - const p = await plan(k, ceiling, config) + const p = await plan(k, ceiling, receiptPolicy, config) const existing = await reserved(k) let state = existing.length === 0 @@ -460,9 +541,14 @@ export async function installSnapshotJournalMysqlGeneration( if (table.seed === 'clock') await k.raw(sql + ' SELECT 1 id,? ceiling', [ceiling]) else if (table.seed === 'bootstrap') await k.raw(sql + ' SELECT 1 id,0 stream,NULL `cursor`,NULL rowLimit,0 rowsUsed') + else if (table.seed === 'retention') + await k.raw(sql + " SELECT 1 id,'0' floor,? receiptLimit,? receiptLifetimeMs", [ + p.receiptPolicy.receiptLimit, + p.receiptPolicy.receiptLifetimeMs + ]) else await k.raw(sql) } - await validateObject(k, object, state, p.context) + await validateObject(k, object, state, p.context, p.receiptPolicy) } const updated = await k(SNAPSHOT_JOURNAL_MYSQL_INTENT) .where({ id: 1, epoch: state.epoch, nextObject: i, complete: 0 }) @@ -478,9 +564,10 @@ export async function installSnapshotJournalMysqlGeneration( export async function readSnapshotJournalMysqlGeneration( k: Knex, ceiling: SnapshotJournalRevision, + receiptPolicy: SnapshotJournalReceiptPolicy, config?: Knex.MigratorConfig ): Promise { - const p = await plan(k, ceiling, config), + const p = await plan(k, ceiling, receiptPolicy, config), state = await readSnapshotJournalMysqlIntent(k, p.binding) if (state.nextObject !== p.objects.length) return invalid() await validateObjects(k, p, state) @@ -491,13 +578,14 @@ export async function readSnapshotJournalMysqlGeneration( export async function completeSnapshotJournalMysqlGeneration( k: Knex, ceiling: SnapshotJournalRevision, + receiptPolicy: SnapshotJournalReceiptPolicy, config?: Knex.MigratorConfig ): Promise { - const validated = await readSnapshotJournalMysqlGeneration(k, ceiling, config) + const validated = await readSnapshotJournalMysqlGeneration(k, ceiling, receiptPolicy, config) return await k.transaction(async t => { if (!(await t('snapshot_journal_clock').where('id', 1).forUpdate().noWait().first('id'))) return invalid() const state = await readSnapshotJournalMysqlIntent(t, validated) - if (state.epoch !== validated.epoch || state.nextObject !== 57) return invalid() + if (state.epoch !== validated.epoch || state.nextObject !== objectCount) return invalid() const progress = await t('snapshot_journal_bootstrap').where('id', 1).first('stream', 'cursor') if ( !progress || @@ -507,7 +595,7 @@ export async function completeSnapshotJournalMysqlGeneration( ) return invalid() const updated = await t(SNAPSHOT_JOURNAL_MYSQL_INTENT) - .where({ id: 1, epoch: state.epoch, nextObject: 57 }) + .where({ id: 1, epoch: state.epoch, nextObject: objectCount }) .update({ complete: 1 }) if (updated !== 1) return invalid() return { ...state, complete: true, enabled: true } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts index b06984e8f..cd9cd8f41 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts @@ -9,10 +9,12 @@ import { snapshotJournalSqliteObserverSql } from './SnapshotJournalSqliteObserve import { snapshotJournalMysqlObserverSql } from './SnapshotJournalMysqlObservers' import { snapshotJournalRevision as rev } from './SnapshotJournalRevision' +const journalReceiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } + test.each([ ['bootstrap', (k: Knex) => copySnapshotJournalBootstrapPage(k, 1000000)], - ['SQLite generation read', (k: Knex) => readSnapshotJournalSqliteGeneration(k)], - ['SQLite generation completion', (k: Knex) => completeSnapshotJournalSqliteGeneration(k)], + ['SQLite generation read', (k: Knex) => readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)], + ['SQLite generation completion', (k: Knex) => completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)], ['high-water', (k: Knex) => readSnapshotJournalHighWater(k, 1, rev('0'), rev('0'))], ['SQLite observers', (k: Knex) => snapshotJournalSqliteObserverSql(k)], ['MySQL observers', (k: Knex) => snapshotJournalMysqlObserverSql(k)] diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts new file mode 100644 index 000000000..1c9bb1167 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts @@ -0,0 +1,249 @@ +import { knex, type Knex } from 'knex' +import { createHash } from 'node:crypto' +import { + snapshotJournalReceiptBinding, + snapshotJournalReceiptDdl, + recordSnapshotJournalReceipt, + readSnapshotJournalReceipt, + collectSnapshotJournalReceipts, + type SnapshotJournalReceiptBinding +} from './SnapshotJournalReceipt' +import { snapshotJournalRevision } from './SnapshotJournalRevision' + +const binding: SnapshotJournalReceiptBinding = { + backend: '11'.repeat(32), + epoch: '12345678-1234-4234-9234-123456789012', + source: '22'.repeat(32), + schema: '33'.repeat(32), + storageIdentity: 'synthetic-storage', + identityKey: '02' + '44'.repeat(32), + userId: 1, + chain: 'test' +} +const request = (n: number) => ({ + requestId: n.toString(16).padStart(64, '0'), + highWater: snapshotJournalRevision('9007199254740993'), + expiresAt: Date.now() + 60000 +}) +let k: Knex +beforeEach(async () => { + k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + await k.raw('PRAGMA busy_timeout=0') + for (const sql of snapshotJournalReceiptDdl(k)) await k.raw(sql) + await k('snapshot_journal_retention').insert({ id: 1, floor: '0', receiptLimit: 128, receiptLifetimeMs: 2592000000 }) +}) +afterEach(async () => { + await k.destroy() +}) + +test('binding has a stable independent digest and distinguishes every bound field', () => { + const expected = createHash('sha256') + .update( + 'snapshot-journal-receipt-binding-v1\n' + + JSON.stringify([ + binding.backend, + binding.epoch, + binding.source, + binding.schema, + binding.storageIdentity, + binding.identityKey, + binding.userId, + binding.chain + ]) + ) + .digest('hex') + expect(snapshotJournalReceiptBinding(binding)).toBe(expected) + const changes: Partial[] = [ + { backend: '55'.repeat(32) }, + { epoch: '12345678-1234-4234-9234-123456789013' }, + { source: '55'.repeat(32) }, + { schema: '55'.repeat(32) }, + { storageIdentity: 'synthetic-other' }, + { identityKey: '03' + '44'.repeat(32) }, + { userId: 2 }, + { chain: 'main' } + ] + for (const change of changes) expect(snapshotJournalReceiptBinding({ ...binding, ...change })).not.toBe(expected) +}) + +test.each([ + { backend: 'z'.repeat(64) }, + { epoch: '12345678-1234-1234-9234-123456789012' }, + { source: '11'.repeat(33) }, + { schema: '' }, + { identityKey: '04' + '44'.repeat(32) }, + { userId: 0 }, + { userId: 1.5 }, + { userId: Number.MAX_SAFE_INTEGER + 1 }, + { chain: 'unknown' }, + { storageIdentity: '' }, + { storageIdentity: 'x'.repeat(257) }, + { storageIdentity: '\ud800' }, + { storageIdentity: 'é'.repeat(129) } +])('rejects invalid binding %o before creating a receipt', async change => { + await expect( + k.transaction(t => recordSnapshotJournalReceipt(t, { ...binding, ...change }, request(1))) + ).rejects.toThrow() + expect(await k('snapshot_journal_receipts')).toHaveLength(0) +}) + +test('exact retry preserves immutable receipt/floor and binding changes refuse', async () => { + const input = request(1) + const first = await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + expect(first).toEqual({ ...input, binding: snapshotJournalReceiptBinding(binding), floor: '0' }) + await k('snapshot_journal_retention').update({ floor: '5' }) + const retry = await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + expect(retry).toEqual(first) + expect(await k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).toEqual(first) + for (const other of [ + { ...input, highWater: snapshotJournalRevision('9007199254740994') }, + { ...input, expiresAt: input.expiresAt + 1 } + ]) + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, other))).rejects.toThrow() + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, { ...binding, userId: 2 }, input))).rejects.toThrow() + await expect( + k.transaction(t => readSnapshotJournalReceipt(t, { ...binding, backend: '55'.repeat(32) }, input)) + ).rejects.toThrow() + expect(await k('snapshot_journal_receipts')).toHaveLength(1) +}) + +test('receipt and floor changes roll back together; committed receipt survives lost acknowledgement', async () => { + const input = request(1) + await expect( + k.transaction(async t => { + await t('snapshot_journal_retention').update({ floor: '7' }) + await recordSnapshotJournalReceipt(t, binding, input) + throw new Error('before commit') + }) + ).rejects.toThrow('before commit') + expect(await k('snapshot_journal_receipts')).toHaveLength(0) + expect((await k('snapshot_journal_retention').first()).floor).toBe('0') + await expect( + (async () => { + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + throw new Error('after commit') + })() + ).rejects.toThrow('after commit') + expect(await k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).toMatchObject(input) +}) + +test('a missing restored receipt refuses even after journal revision moves beyond the checkpoint', async () => { + const input = request(1) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + const exact = await k('snapshot_journal_receipts').first() + await k('snapshot_journal_receipts').delete() + await k.transaction(t => + recordSnapshotJournalReceipt(t, binding, { ...request(2), highWater: snapshotJournalRevision('9007199254740994') }) + ) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() + await k('snapshot_journal_receipts').insert(exact) + expect(await k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).toMatchObject(input) + await k('snapshot_journal_retention').update({ floor: '9007199254740994' }) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() +}) + +test('capacity includes expired records until bounded collection commits, and exact expired requests cannot reopen', async () => { + await k('snapshot_journal_retention').update({ receiptLimit: 2 }) + const a = request(1), + b = request(2), + c = request(3) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, b)) + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, c))).rejects.toThrow() + await k('snapshot_journal_receipts').where('requestId', a.requestId).update({ expiresAt: 1 }) + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, c))).rejects.toThrow() + await expect( + k.transaction(async t => { + expect(await collectSnapshotJournalReceipts(t)).toBe(1) + throw new Error('collector rollback') + }) + ).rejects.toThrow('collector rollback') + expect(await k('snapshot_journal_receipts')).toHaveLength(2) + expect(await k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(1) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, c)) + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, { ...a, expiresAt: 1 }))).rejects.toThrow() + expect(await k.transaction(t => readSnapshotJournalReceipt(t, binding, b))).toMatchObject(b) +}) + +test('collector uses at most64 indexed candidates and releases no unexpired receipt', async () => { + const common = { binding: snapshotJournalReceiptBinding(binding), highWater: '9', floor: '0', expiresAt: 1 } + for (let i = 1; i <= 128; i += 32) + await k('snapshot_journal_receipts').insert( + Array.from({ length: 32 }, (_, offset) => ({ ...common, requestId: (i + offset).toString(16).padStart(64, '0') })) + ) + await k('snapshot_journal_receipts') + .where('requestId', request(128).requestId) + .update({ expiresAt: Date.now() + 60000 }) + const query = k('snapshot_journal_receipts') + .where('expiresAt', '<=', Date.now()) + .select('requestId') + .orderBy(['expiresAt', 'requestId']) + .limit(64) + .toSQL() + const plan: Array<{ detail: string }> = await k.raw( + 'EXPLAIN QUERY PLAN ' + query.sql, + query.bindings as Knex.RawBinding[] + ) + expect(plan.map(x => x.detail).join(' ')).toContain( + 'SEARCH snapshot_journal_receipts USING COVERING INDEX snapshot_journal_receipts_expiry' + ) + expect(await k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(64) + expect(await k('snapshot_journal_receipts')).toHaveLength(64) + expect(await k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(63) + expect(await k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(0) + expect(await k('snapshot_journal_receipts')).toHaveLength(1) +}) + +test('mutating calls require explicit transactions and a nonwaiting SQLite connection', async () => { + await expect(recordSnapshotJournalReceipt(k, binding, request(1))).rejects.toThrow() + await expect(readSnapshotJournalReceipt(k, binding, request(1))).rejects.toThrow() + await expect(collectSnapshotJournalReceipts(k)).rejects.toThrow() + await k.raw('PRAGMA busy_timeout=1') + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, request(1)))).rejects.toThrow() + await expect(k.transaction(t => collectSnapshotJournalReceipts(t))).rejects.toThrow() +}) + +test('malformed persisted receipts and retention metadata refuse', async () => { + const input = request(1) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + for (const change of [{ binding: 'x' }, { highWater: '01' }, { floor: '9007199254740994' }, { expiresAt: 1 }]) { + await k.transaction(async t => { + await t('snapshot_journal_receipts').update(change) + await expect(readSnapshotJournalReceipt(t, binding, input)).rejects.toThrow() + await t.rollback() + }) + } + await k('snapshot_journal_retention').update({ floor: '01' }) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() +}) + +test('expired, overlong and malformed requests allocate no receipt', async () => { + const input = request(1) + for (const change of [ + { requestId: 'x' }, + { requestId: 'a'.repeat(65) }, + { highWater: '0' }, + { highWater: '01' }, + { expiresAt: 1 }, + { expiresAt: Date.now() + 2592000000 + 60000 }, + { expiresAt: '9007199254740993' } + ]) + await expect( + k.transaction(t => recordSnapshotJournalReceipt(t, binding, { ...input, ...change } as typeof input)) + ).rejects.toThrow() + expect(await k('snapshot_journal_receipts')).toHaveLength(0) +}) + +test('a receipt whose captured floor exceeds the current floor refuses inconsistent state', async () => { + const input = request(1) + await k('snapshot_journal_retention').update({ floor: '10' }) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + await k('snapshot_journal_retention').update({ floor: '9' }) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts new file mode 100644 index 000000000..5c17ff19b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts @@ -0,0 +1,285 @@ +import type { Knex } from 'knex' +import { createHash } from 'node:crypto' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { snapshotArchiveDatabaseNow } from '../archive/SnapshotArchiveSql' +import { + compareSnapshotJournalRevisions, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' + +export const SNAPSHOT_JOURNAL_RECEIPT_LIMIT = 128 +export const SNAPSHOT_JOURNAL_RECEIPT_MAX_LIFETIME = 2592000000 +export interface SnapshotJournalReceiptPolicy { + receiptLimit: number + receiptLifetimeMs: number +} +export interface SnapshotJournalReceiptBinding { + backend: string + epoch: string + source: string + schema: string + storageIdentity: string + identityKey: string + userId: number + chain: string +} +export interface SnapshotJournalReceipt { + requestId: string + binding: string + highWater: SnapshotJournalRevision + floor: SnapshotJournalRevision + expiresAt: number +} +export interface SnapshotJournalRetention { + floor: SnapshotJournalRevision + receiptLimit: number + receiptLifetimeMs: number +} +const digestPattern = /^[0-9a-f]{64}$/ +function invalid(): never { + throw new WERR_INVALID_OPERATION('Invalid, unavailable or expired snapshot journal receipt') +} +function local(k: Knex): boolean { + const client = k.client.config.client + if (client === 'sqlite3' || client === 'better-sqlite3') return true + if (client === 'mysql' || client === 'mysql2') return false + return invalid() +} +function transaction(k: Knex): void { + local(k) + if (!k.isTransaction) invalid() +} +const boundedInteger = (value: unknown, max: number): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= max + +/** Detached installation policy; a generation cannot silently change it on resume. */ +export function snapshotJournalReceiptPolicy(value: SnapshotJournalReceiptPolicy): SnapshotJournalReceiptPolicy { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return invalid() + const { receiptLimit, receiptLifetimeMs } = value + if ( + !boundedInteger(receiptLimit, SNAPSHOT_JOURNAL_RECEIPT_LIMIT) || + !boundedInteger(receiptLifetimeMs, SNAPSHOT_JOURNAL_RECEIPT_MAX_LIFETIME) + ) + return invalid() + return { receiptLimit, receiptLifetimeMs } +} + +/** An exact, bounded digest binds a receipt without retaining profile metadata in it. */ +export function snapshotJournalReceiptBinding(value: SnapshotJournalReceiptBinding): string { + if ( + value === null || + typeof value !== 'object' || + ![value.backend, value.source, value.schema].every(part => typeof part === 'string' && digestPattern.test(part)) || + typeof value.epoch !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.epoch) || + typeof value.storageIdentity !== 'string' || + Buffer.byteLength(value.storageIdentity, 'utf8') < 1 || + Buffer.byteLength(value.storageIdentity, 'utf8') > 256 || + Buffer.from(value.storageIdentity, 'utf8').toString('utf8') !== value.storageIdentity || + typeof value.identityKey !== 'string' || + !/^(02|03)[0-9a-f]{64}$/.test(value.identityKey) || + !boundedInteger(value.userId, Number.MAX_SAFE_INTEGER) || + !['main', 'test', 'stn', 'ttn', 'tstn', 'mock'].includes(value.chain) + ) + return invalid() + return createHash('sha256') + .update('snapshot-journal-receipt-binding-v1\n') + .update( + JSON.stringify([ + value.backend, + value.epoch, + value.source, + value.schema, + value.storageIdentity, + value.identityKey, + value.userId, + value.chain + ]) + ) + .digest('hex') +} + +/** Owned generation DDL only. This helper neither adopts objects nor registers a migration. */ +export function snapshotJournalReceiptDdl(k: Knex): string[] { + const sqlite = local(k) + const suffix = sqlite ? '' : ' ENGINE=InnoDB DEFAULT CHARACTER SET ascii COLLATE ascii_bin ROW_FORMAT=DYNAMIC' + return [ + 'CREATE TABLE snapshot_journal_retention(id INTEGER NOT NULL PRIMARY KEY,floor VARCHAR(19) NOT NULL,receiptLimit INTEGER NOT NULL,receiptLifetimeMs BIGINT NOT NULL,CHECK(id=1),CHECK(receiptLimit BETWEEN 1 AND 128),CHECK(receiptLifetimeMs BETWEEN 1 AND 2592000000))' + + suffix, + 'CREATE TABLE snapshot_journal_receipts(requestId VARCHAR(64) NOT NULL PRIMARY KEY,binding VARCHAR(64) NOT NULL,highWater VARCHAR(19) NOT NULL,floor VARCHAR(19) NOT NULL,expiresAt BIGINT NOT NULL,CHECK(expiresAt BETWEEN 1 AND 9007199254740991)' + + (sqlite ? ')' : ',KEY snapshot_journal_receipts_expiry(expiresAt,requestId))') + + suffix, + ...(sqlite + ? ['CREATE INDEX snapshot_journal_receipts_expiry ON snapshot_journal_receipts(expiresAt,requestId)'] + : []) + ] +} +async function sharedRows(k: Knex, query: Knex.QueryBuilder): Promise>> { + if (local(k)) return await query + // Knex's MySQL forShare emits legacy LOCK IN SHARE MODE, which cannot + // take NOWAIT. Use the current-read MySQL8 form with the same bindings. + const sql = query.toSQL() + const [rows]: Array>> = await k.raw( + sql.sql + ' FOR SHARE NOWAIT', + sql.bindings as Knex.RawBinding[] + ) + return rows +} +function receiptQuery(k: Knex): Knex.QueryBuilder { + return k('snapshot_journal_receipts').select( + 'expiresAt', + ...[ + ['requestId', 65], + ['binding', 65], + ['highWater', 20], + ['floor', 20] + ].map(([field, length]) => k.raw('substr(??,1,?) AS ??', [field, length, field] as Knex.RawBinding[])) + ) +} +async function retention(k: Knex, lock: boolean): Promise { + transaction(k) + if (lock && local(k)) { + const mode: Array<{ timeout: number }> = await k.raw('PRAGMA busy_timeout') + if (mode.length !== 1 || mode[0].timeout !== 0) return invalid() + await k('snapshot_journal_retention').where('id', 1).update({ id: 1 }) + } + const query = k('snapshot_journal_retention') + .select('id', 'receiptLimit', 'receiptLifetimeMs', k.raw('substr(??,1,20) AS ??', ['floor', 'floor'])) + .limit(2) + if (lock && !local(k)) query.forUpdate().noWait() + const rows = lock ? await query : await sharedRows(k, query) + if (rows.length === 1) rows[0].receiptLifetimeMs = storedInteger(rows[0].receiptLifetimeMs) + if ( + rows.length !== 1 || + rows[0].id !== 1 || + !boundedInteger(rows[0].receiptLimit, SNAPSHOT_JOURNAL_RECEIPT_LIMIT) || + !boundedInteger(rows[0].receiptLifetimeMs, SNAPSHOT_JOURNAL_RECEIPT_MAX_LIFETIME) + ) + return invalid() + return { + floor: snapshotJournalRevision(rows[0].floor), + receiptLimit: rows[0].receiptLimit, + receiptLifetimeMs: rows[0].receiptLifetimeMs + } +} +async function now(k: Knex): Promise { + const value = await snapshotArchiveDatabaseNow(k) + if (!boundedInteger(value, Number.MAX_SAFE_INTEGER)) return invalid() + return value +} +function storedInteger(value: unknown): number { + if (typeof value === 'string' && /^(?:0|[1-9][0-9]{0,15})$/.test(value)) value = Number(value) + if (!boundedInteger(value, Number.MAX_SAFE_INTEGER)) return invalid() + return value +} +function receipt(value: Record, stored = false): SnapshotJournalReceipt { + if (stored) value = { ...value, expiresAt: storedInteger(value.expiresAt) } + if ( + typeof value.requestId !== 'string' || + !digestPattern.test(value.requestId) || + typeof value.binding !== 'string' || + !digestPattern.test(value.binding) || + !boundedInteger(value.expiresAt, Number.MAX_SAFE_INTEGER) + ) + return invalid() + const highWater = snapshotJournalRevision(value.highWater), + floor = snapshotJournalRevision(value.floor) + if (highWater === '0' || compareSnapshotJournalRevisions(highWater, floor) < 0) return invalid() + return { requestId: value.requestId, binding: value.binding, highWater, floor, expiresAt: value.expiresAt } +} + +/** Persist inside the caller's short writer-barrier transaction, after its separately + * reserved reader has pinned a coherent view. Never publish before that transaction + * commits. A receipt is a prefix proof, not a resumable database read transaction. + * A retry may repeat this exact proof; opening another view requires another ID. */ +export async function recordSnapshotJournalReceipt( + k: Knex, + binding: SnapshotJournalReceiptBinding, + request: { requestId: string; highWater: SnapshotJournalRevision; expiresAt: number } +): Promise { + const expected = snapshotJournalReceiptBinding(binding) + const input = receipt({ ...request, binding: expected, floor: '0' }) + const state = await retention(k, true), + time = await now(k) + if ( + input.expiresAt <= time || + input.expiresAt - time > state.receiptLifetimeMs || + compareSnapshotJournalRevisions(input.highWater, state.floor) < 0 + ) + return invalid() + const priorQuery = receiptQuery(k).where('requestId', input.requestId).first() + if (!local(k)) priorQuery.forUpdate().noWait() + const prior = await priorQuery + if (prior !== undefined) { + const existing = receipt(prior, true) + if ( + existing.binding !== expected || + existing.highWater !== input.highWater || + existing.expiresAt !== input.expiresAt || + compareSnapshotJournalRevisions(existing.highWater, state.floor) < 0 || + compareSnapshotJournalRevisions(existing.floor, state.floor) > 0 + ) + return invalid() + return existing + } + // Capacity includes expired rows until a bounded collector actually commits deletion. + const occupiedQuery = k('snapshot_journal_receipts') + .select(k.raw('1 AS occupied')) + .orderBy('requestId') + .limit(state.receiptLimit + 1) + if (!local(k)) occupiedQuery.forUpdate().noWait() + const occupied = await occupiedQuery + if (occupied.length >= state.receiptLimit) return invalid() + const result = { ...input, floor: state.floor } + await k('snapshot_journal_receipts').insert(result) + return result +} + +/** Read the floor and exact receipt in one coherent transaction. */ +export async function readSnapshotJournalReceipt( + k: Knex, + binding: SnapshotJournalReceiptBinding, + expected: { requestId: string; highWater: SnapshotJournalRevision; expiresAt: number } +): Promise { + const bound = snapshotJournalReceiptBinding(binding) + const requested = receipt({ ...expected, binding: bound, floor: '0' }) + const state = await retention(k, false), + time = await now(k) + const [stored] = await sharedRows(k, receiptQuery(k).where('requestId', requested.requestId).limit(1)) + if (stored === undefined) return invalid() + const result = receipt(stored, true) + if ( + result.binding !== bound || + result.highWater !== requested.highWater || + result.expiresAt !== requested.expiresAt || + result.expiresAt <= time || + compareSnapshotJournalRevisions(result.highWater, state.floor) < 0 || + compareSnapshotJournalRevisions(result.floor, state.floor) > 0 + ) + return invalid() + return result +} + +/** At most 64 indexed expired receipts per caller-owned transaction. Retiring a + * receipt never reopens its expired request; capacity is released only on commit. */ +export async function collectSnapshotJournalReceipts(k: Knex): Promise { + await retention(k, true) + const time = await now(k) + const query = k('snapshot_journal_receipts') + .where('expiresAt', '<=', time) + .select(k.raw('substr(??,1,65) AS ??', ['requestId', 'requestId'])) + .orderBy(['expiresAt', 'requestId']) + .limit(64) + if (!local(k)) query.forUpdate().noWait() + const rows: Array<{ requestId: unknown }> = await query + if (rows.some(row => typeof row.requestId !== 'string' || !digestPattern.test(row.requestId))) return invalid() + if (rows.length) + await k('snapshot_journal_receipts') + .whereIn( + 'requestId', + rows.map(row => row.requestId as string) + ) + .delete() + return rows.length +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts new file mode 100644 index 000000000..856685d07 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts @@ -0,0 +1,217 @@ +import { knex, type Knex } from 'knex' +import { + snapshotJournalReceiptDdl, + snapshotJournalReceiptBinding, + recordSnapshotJournalReceipt, + readSnapshotJournalReceipt, + collectSnapshotJournalReceipts, + type SnapshotJournalReceiptBinding +} from './SnapshotJournalReceipt' +import { snapshotJournalRevision } from './SnapshotJournalRevision' + +const binding: SnapshotJournalReceiptBinding = { + backend: '11'.repeat(32), + epoch: '12345678-1234-4234-9234-123456789012', + source: '22'.repeat(32), + schema: '33'.repeat(32), + storageIdentity: 'synthetic-storage', + identityKey: '02' + '44'.repeat(32), + userId: 1, + chain: 'test' +} +const time = 1700000000000 +const input = (id: number) => ({ + requestId: id.toString(16).padStart(64, '0'), + highWater: snapshotJournalRevision('9007199254740993'), + expiresAt: time + 60000 +}) +interface Query { + sql: string + bindings: Knex.RawBinding[] + method: string + response?: unknown +} + +// Real MySQL query compilation/response processing, backed by SQLite for DML +// and rollback. The independent native fixture proves MySQL isolation/locks. +async function fixture(strings: boolean) { + const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + k = knex({ client: 'mysql2' }) + for (const sql of snapshotJournalReceiptDdl(db)) await db.raw(sql) + await db('snapshot_journal_retention').insert({ id: 1, floor: '0', receiptLimit: 2, receiptLifetimeMs: 2592000000 }) + const queries: Query[] = [], + state = { failLock: false, now: time } + const connection = { + __knexUid: 'receipt-driver', + query: ( + options: { sql: string }, + bindings: Knex.RawBinding[] | undefined, + callback: (error: unknown, rows?: unknown, fields?: unknown) => void + ) => { + void k.client._query(connection, { sql: options.sql, bindings: bindings ?? [], method: 'raw' }).then( + (result: { response: [unknown, unknown] }) => callback(null, ...result.response), + (error: unknown) => callback(error) + ) + } + } + k.client.acquireConnection = async () => connection + k.client.releaseConnection = async () => undefined + k.client._query = async (_connection: unknown, q: Query) => { + queries.push({ sql: q.sql, bindings: q.bindings, method: q.method }) + const respond = (rows: unknown) => { + q.response = [rows, []] + return q + } + if (state.failLock && /(?:for update|FOR SHARE) NOWAIT$/i.test(q.sql)) + throw Object.assign(new Error('owned fixture lock busy'), { code: 'ER_LOCK_NOWAIT' }) + if (q.sql === 'SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000) AS now') + return respond([{ now: strings ? String(state.now) : state.now }]) + const sql = q.sql.replace(/ (?:for (?:share|update)(?: nowait)?|lock in share mode)$/i, '') + const result = await db.raw(sql, q.bindings) + if (Array.isArray(result)) { + if (strings) + for (const row of result) + for (const field of ['expiresAt', 'receiptLifetimeMs']) + if (row[field] !== undefined) row[field] = String(row[field]) + return respond(result) + } + return respond({ affectedRows: result?.changes ?? 0, insertId: result?.lastInsertRowid ?? 0 }) + } + return { + db, + k, + queries, + state, + close: async () => { + await k.destroy() + await db.destroy() + } + } +} + +test.each([false, true])( + 'MySQL receipt operations preserve exact values and retry/rollback semantics (string BIGINT: %s)', + async strings => { + const f = await fixture(strings) + try { + const a = input(1), + b = input(2), + expected = { ...a, binding: snapshotJournalReceiptBinding(binding), floor: '0' } + expect(await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, a))).toEqual(expected) + expect(await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, a))).toEqual(expected) + expect(await f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).toEqual(expected) + await expect( + f.k.transaction(async t => { + await recordSnapshotJournalReceipt(t, binding, b) + throw Error('rollback') + }) + ).rejects.toThrow('rollback') + expect(await f.db('snapshot_journal_receipts')).toHaveLength(1) + await expect(f.k.transaction(t => readSnapshotJournalReceipt(t, { ...binding, userId: 2 }, a))).rejects.toThrow() + await f.db('snapshot_journal_retention').update({ floor: '9007199254740994' }) + await expect(f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).rejects.toThrow() + expect(f.queries.some(q => /snapshot_journal_retention` limit \? for update nowait$/i.test(q.sql))).toBe(true) + const receiptReads = f.queries.filter( + q => q.sql.startsWith('select ') && q.sql.includes('snapshot_journal_receipts') + ) + expect(receiptReads.length).toBeGreaterThan(0) + expect(receiptReads.every(q => /FOR (?:SHARE|UPDATE) NOWAIT$/i.test(q.sql))).toBe(true) + expect(receiptReads.every(q => !q.sql.includes('select *'))).toBe(true) + } finally { + await f.close() + } + } +) + +test('MySQL expired capacity remains charged until collection commits', async () => { + const f = await fixture(true) + try { + const a = input(1), + b = input(2), + c = input(3) + await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)) + await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, b)) + await f.db('snapshot_journal_receipts').where('requestId', a.requestId).update({ expiresAt: 1 }) + await expect(f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, c))).rejects.toThrow() + await expect( + f.k.transaction(async t => { + expect(await collectSnapshotJournalReceipts(t)).toBe(1) + throw Error('collection rollback') + }) + ).rejects.toThrow('collection rollback') + await expect(f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, c))).rejects.toThrow() + expect(await f.k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(1) + await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, c)) + expect(await f.k.transaction(t => readSnapshotJournalReceipt(t, binding, b))).toMatchObject(b) + expect( + f.queries + .filter(q => q.sql.startsWith('select 1 AS occupied')) + .every(q => /limit \? for update nowait$/i.test(q.sql)) + ).toBe(true) + expect( + f.queries.some( + q => + q.sql.includes('order by `expiresAt` asc, `requestId` asc limit ? for update nowait') && + q.bindings.at(-1) === 64 + ) + ).toBe(true) + } finally { + await f.close() + } +}) + +test('MySQL lock refusal propagates and writes no partial receipt', async () => { + const f = await fixture(false) + try { + f.state.failLock = true + await expect(f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, input(1)))).rejects.toMatchObject({ + code: 'ER_LOCK_NOWAIT' + }) + await expect(f.k.transaction(t => readSnapshotJournalReceipt(t, binding, input(1)))).rejects.toMatchObject({ + code: 'ER_LOCK_NOWAIT' + }) + await expect(f.k.transaction(t => collectSnapshotJournalReceipts(t))).rejects.toMatchObject({ + code: 'ER_LOCK_NOWAIT' + }) + expect(await f.db('snapshot_journal_receipts')).toEqual([]) + } finally { + await f.close() + } +}) + +test('bounded stored projections reject oversized identities and revisions', async () => { + const f = await fixture(true) + try { + const a = input(1) + await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)) + const stored = await f.db('snapshot_journal_receipts').first() + for (const change of [ + { binding: 'a'.repeat(100000) }, + { highWater: '1'.repeat(100000) }, + { floor: '1'.repeat(100000) } + ]) { + await f.db('snapshot_journal_receipts').update(change) + await expect(f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).rejects.toThrow() + await f.db('snapshot_journal_receipts').update(stored) + } + await f.db('snapshot_journal_receipts').update({ requestId: 'f'.repeat(100000), expiresAt: 1 }) + await expect(f.k.transaction(t => collectSnapshotJournalReceipts(t))).rejects.toThrow() + expect(await f.db('snapshot_journal_receipts')).toHaveLength(1) + } finally { + await f.close() + } +}) + +test('MySQL receipt tables carry the expiry index in the atomic table definition', async () => { + const k = knex({ client: 'mysql2' }) + try { + const ddl = snapshotJournalReceiptDdl(k) + expect(ddl).toHaveLength(2) + expect(ddl[1]).toContain('KEY snapshot_journal_receipts_expiry(expiresAt,requestId)') + expect( + ddl.every(sql => sql.endsWith('ENGINE=InnoDB DEFAULT CHARACTER SET ascii COLLATE ascii_bin ROW_FORMAT=DYNAMIC')) + ).toBe(true) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts index f3addedc9..4b473a0a2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts @@ -11,6 +11,9 @@ import { } from './SnapshotJournalSqliteGeneration' import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' + +const journalReceiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } + let ceiling: SnapshotJournalRevision beforeEach(() => { ceiling = snapshotJournalRevision('1000000') @@ -44,21 +47,21 @@ test('atomic installation resumes its epoch and publishes only completed durable const { k, source } = await fixture() try { const rows = await k('transactions'), - installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + installed = await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) expect(installed).toMatchObject({ complete: false, enabled: true, ceiling }) expect(installed.epoch).toMatch(/^[0-9a-f-]{36}$/) - await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') + await expect(completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).rejects.toThrow('Invalid or unowned') await copySnapshotJournalBootstrapPage(k, 1000000) const progress = await k('snapshot_journal_bootstrap').first() - expect(await installSnapshotJournalSqliteGeneration(k, ceiling)).toEqual(installed) + expect(await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).toEqual(installed) expect(await k('snapshot_journal_bootstrap').first()).toEqual(progress) await finish(k) - expect(await readSnapshotJournalSqliteGeneration(k)).toEqual(installed) - expect(await completeSnapshotJournalSqliteGeneration(k)).toEqual({ + expect(await readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).toEqual(installed) + expect(await completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).toEqual({ ...installed, complete: true }) - expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, complete: true }) + expect(await readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).toEqual({ ...installed, complete: true }) expect(await k('transactions')).toEqual(rows) } finally { await source.destroy() @@ -74,7 +77,9 @@ test.each([ try { await k.raw(ddl) const before = await k('sqlite_master').orderBy(['type', 'name']) - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(await k('sqlite_master').orderBy(['type', 'name'])).toEqual(before) } finally { await source.destroy() @@ -85,14 +90,16 @@ test.each(['observer', 'index', 'same-name view', 'unknown reserved'])( async kind => { const { k, source } = await fixture() try { - await installSnapshotJournalSqliteGeneration(k, ceiling) + await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) if (kind === 'observer') await k.raw('DROP TRIGGER snapshot_journal_scope_0_INSERT') if (kind === 'index') await k.raw('DROP INDEX snapshot_journal_scope_page') if (kind === 'same-name view') await k.raw('CREATE VIEW snapshot_journal_scope_0_INSERT AS SELECT 1') if (kind === 'unknown reserved') await k.raw('CREATE TABLE snapshot_journal_foreign(id INTEGER)') const before = await k('sqlite_master').orderBy(['type', 'name']), rows = await k('transactions') - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(await k('sqlite_master').orderBy(['type', 'name'])).toEqual(before) expect(await k('transactions')).toEqual(rows) } finally { @@ -104,10 +111,10 @@ test('complete source binding includes user observers and preserves literal whit const { k, source } = await fixture() try { await k.raw("CREATE TRIGGER application_user AFTER INSERT ON users BEGIN SELECT 'a b'; END") - await installSnapshotJournalSqliteGeneration(k, ceiling) + await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) await k.raw('DROP TRIGGER application_user') await k.raw("CREATE TRIGGER application_user AFTER INSERT ON users BEGIN SELECT 'a b'; END") - await expect(readSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).rejects.toThrow('Invalid or unowned') } finally { await source.destroy() } @@ -126,7 +133,7 @@ test.each([ ])('persisted %s damage refuses', async kind => { const { k, source } = await fixture() try { - await installSnapshotJournalSqliteGeneration(k, ceiling) + await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) if (kind === 'metadata missing') await k(metadata).delete() if (kind === 'metadata epoch') await k(metadata).update({ epoch: 'foreign' }) if (kind === 'metadata source') await k(metadata).update({ source: '0'.repeat(64) }) @@ -137,7 +144,7 @@ test.each([ if (kind === 'bootstrap extra') await k('snapshot_journal_bootstrap').update({ stream: 0.5 }) if (kind === 'bootstrap cursor') await k('snapshot_journal_bootstrap').update({ cursor: 'x'.repeat(2049) }) if (kind === 'false completion') await k(metadata).update({ complete: 1 }) - await expect(readSnapshotJournalSqliteGeneration(k)).rejects.toThrow() + await expect(readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).rejects.toThrow() } finally { await source.destroy() } @@ -145,14 +152,20 @@ test.each([ test('configured event exhaustion preserves source writes and refuses completion', async () => { const { k, source } = await fixture() try { - const installed = await installSnapshotJournalSqliteGeneration(k, snapshotJournalRevision('1')) + const installed = await installSnapshotJournalSqliteGeneration( + k, + snapshotJournalRevision('1'), + journalReceiptPolicy + ) await k('tx_labels').where('txLabelId', 1).update({ label: 'first' }) await k('tx_labels').where('txLabelId', 1).update({ label: 'ordinary after exhaustion' }) expect((await k('tx_labels').where('txLabelId', 1).first()).label).toBe('ordinary after exhaustion') - expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, enabled: false }) + expect(await readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).toEqual({ ...installed, enabled: false }) await k('snapshot_journal_bootstrap').update({ stream: 17, cursor: null, rowLimit: 1000000 }) - await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await source.destroy() } @@ -171,12 +184,12 @@ test.each([ } k.on('query', listener) try { - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toBe(failure) + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toBe(failure) } finally { k.off('query', listener) } expect(await k('sqlite_master').orderBy(['type', 'name'])).toEqual(before) - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).resolves.toMatchObject({ + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).resolves.toMatchObject({ complete: false, enabled: true }) @@ -187,7 +200,7 @@ test.each([ test('completion rollback retains the unfinished generation and can resume', async () => { const { k, source } = await fixture() try { - const installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + const installed = await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) await finish(k) const failure = new Error('synthetic completion failure'), listener = (query: { sql: string }) => { @@ -195,12 +208,12 @@ test('completion rollback retains the unfinished generation and can resume', asy } k.on('query', listener) try { - await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toBe(failure) + await expect(completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).rejects.toBe(failure) } finally { k.off('query', listener) } - expect(await readSnapshotJournalSqliteGeneration(k)).toEqual(installed) - await expect(completeSnapshotJournalSqliteGeneration(k)).resolves.toMatchObject({ + expect(await readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).toEqual(installed) + await expect(completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).resolves.toMatchObject({ complete: true }) } finally { @@ -214,10 +227,14 @@ test('unsupported client and missing published SQLite prerequisites refuse befor useNullAsDefault: true }) try { - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(await k('sqlite_master')).toEqual([]) k.client.config.client = 'mysql2' - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) } finally { await k.destroy() } @@ -228,7 +245,7 @@ test.each(['zero ceiling', 'invalid clock reason', 'replaced observer'])( async kind => { const { k, source } = await fixture() try { - await installSnapshotJournalSqliteGeneration(k, ceiling) + await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) if (kind === 'zero ceiling') await k(metadata).update({ ceiling: '0' }) if (kind === 'invalid clock reason') { await k.raw('PRAGMA ignore_check_constraints=ON') @@ -240,7 +257,7 @@ test.each(['zero ceiling', 'invalid clock reason', 'replaced observer'])( await k.raw('CREATE TRIGGER snapshot_journal_scope_0_INSERT AFTER INSERT ON transactions BEGIN SELECT 1; END') } const before = await k('transactions') - await expect(readSnapshotJournalSqliteGeneration(k)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).rejects.toThrow('Invalid or unowned') expect(await k('transactions')).toEqual(before) } finally { await source.destroy() @@ -261,7 +278,9 @@ test.each(['rows', 'definition', 'aggregate'])('source binding refuses an oversi ) } const before = await k('sqlite_master').select('type', 'name', 'sql').orderBy(['type', 'name']) - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(await k('sqlite_master').select('type', 'name', 'sql').orderBy(['type', 'name'])).toEqual(before) } finally { await source.destroy() @@ -274,7 +293,9 @@ test('invalid generated DDL cannot enter the persisted SQLite ownership plan', a .spyOn(observers, 'snapshotJournalSqliteObserverSql') .mockResolvedValue(['CREATE TABLE application_table(id INTEGER)']) try { - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(await k('sqlite_master').where('name', 'application_table')).toEqual([]) } finally { spy.mockRestore() @@ -294,7 +315,9 @@ test.each(['users', 'settings'])('source metadata must include %s in the ownersh } k.on('query-response', listener) try { - await expect(installSnapshotJournalSqliteGeneration(k, ceiling)).rejects.toThrow('Invalid or unowned') + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy)).rejects.toThrow( + 'Invalid or unowned' + ) expect(altered).toBe(true) } finally { k.off('query-response', listener) @@ -306,13 +329,13 @@ test('legacy SQLite alias preserves installation identity through completion', a const { k, source } = await fixture() k.client.config.client = 'sqlite3' try { - const installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + const installed = await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) await finish(k) - expect(await completeSnapshotJournalSqliteGeneration(k)).toEqual({ + expect(await completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).toEqual({ ...installed, complete: true }) - expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, complete: true }) + expect(await readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).toEqual({ ...installed, complete: true }) } finally { await source.destroy() } @@ -326,10 +349,10 @@ test.each(['install', 'read', 'complete'])( const k = { client: { config: { client: 'mysql2' } }, transaction, raw } as unknown as Knex const result = operation === 'install' - ? installSnapshotJournalSqliteGeneration(k, ceiling) + ? installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) : operation === 'read' - ? readSnapshotJournalSqliteGeneration(k) - : completeSnapshotJournalSqliteGeneration(k) + ? readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy) + : completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy) await expect(result).rejects.toThrow('Invalid or unowned SQLite snapshot journal generation') expect(transaction).not.toHaveBeenCalled() expect(raw).not.toHaveBeenCalled() @@ -339,9 +362,9 @@ test.each(['install', 'read', 'complete'])( test('empty SQLite generation event window refuses before transaction admission', async () => { const transaction = jest.fn(), k = { client: { config: { client: 'sqlite3' } }, transaction } as unknown as Knex - await expect(installSnapshotJournalSqliteGeneration(k, snapshotJournalRevision('0'))).rejects.toThrow( - 'Invalid or unowned SQLite snapshot journal generation' - ) + await expect( + installSnapshotJournalSqliteGeneration(k, snapshotJournalRevision('0'), journalReceiptPolicy) + ).rejects.toThrow('Invalid or unowned SQLite snapshot journal generation') expect(transaction).not.toHaveBeenCalled() }) @@ -350,11 +373,11 @@ test.each(['prefix', 'suffix'])( async side => { const { k, source } = await fixture() try { - const installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + const installed = await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) await k(metadata).update({ epoch: side === 'prefix' ? installed.epoch + 'x' : 'x' + installed.epoch }) - await expect(readSnapshotJournalSqliteGeneration(k)).rejects.toThrow( + await expect(readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).rejects.toThrow( 'Invalid or unowned SQLite snapshot journal generation' ) } finally { @@ -368,10 +391,13 @@ test.each(['revision-exhausted', 'key-out-of-range'])( async reason => { const { k, source } = await fixture() try { - const installed = await installSnapshotJournalSqliteGeneration(k, ceiling) + const installed = await installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) await k('snapshot_journal_clock').update({ enabled: 0, reason }) - expect(await readSnapshotJournalSqliteGeneration(k)).toEqual({ ...installed, enabled: false }) - await expect(completeSnapshotJournalSqliteGeneration(k)).rejects.toThrow( + expect(await readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).toEqual({ + ...installed, + enabled: false + }) + await expect(completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy)).rejects.toThrow( 'Invalid or unowned SQLite snapshot journal generation' ) } finally { @@ -379,3 +405,80 @@ test.each(['revision-exhausted', 'key-out-of-range'])( } } ) + +test('receipt policy is explicit, detached and immutable throughout generation resume', async () => { + const { k, source } = await fixture() + try { + const policy = { receiptLimit: 2, receiptLifetimeMs: 1000 } + const opening = installSnapshotJournalSqliteGeneration(k, ceiling, policy) + policy.receiptLimit = 3 + const installed = await opening + const original = { receiptLimit: 2, receiptLifetimeMs: 1000 } + expect(await k('snapshot_journal_retention').first()).toEqual({ id: 1, floor: '0', ...original }) + expect(await installSnapshotJournalSqliteGeneration(k, ceiling, original)).toEqual(installed) + for (const changed of [policy, { ...original, receiptLifetimeMs: 1001 }]) { + await expect(installSnapshotJournalSqliteGeneration(k, ceiling, changed)).rejects.toThrow('Invalid or unowned') + await expect(readSnapshotJournalSqliteGeneration(k, changed)).rejects.toThrow('Invalid or unowned') + await expect(completeSnapshotJournalSqliteGeneration(k, changed)).rejects.toThrow('Invalid or unowned') + } + expect(await k('snapshot_journal_retention').first()).toEqual({ id: 1, floor: '0', ...original }) + } finally { + await source.destroy() + } +}) + +test.each([ + null, + undefined, + [], + {}, + { receiptLimit: 0, receiptLifetimeMs: 1 }, + { receiptLimit: 129, receiptLifetimeMs: 1 }, + { receiptLimit: 1.5, receiptLifetimeMs: 1 }, + { receiptLimit: 1, receiptLifetimeMs: 0 }, + { receiptLimit: 1, receiptLifetimeMs: 2592000001 }, + { receiptLimit: 1, receiptLifetimeMs: NaN } +])('invalid receipt installation policy %p fails before source access', async policy => { + const k = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + const queries: string[] = [] + k.on('query', (query: { sql: string }) => queries.push(query.sql)) + try { + await expect( + installSnapshotJournalSqliteGeneration(k, ceiling, policy as typeof journalReceiptPolicy) + ).rejects.toThrow() + await expect(readSnapshotJournalSqliteGeneration(k, policy as typeof journalReceiptPolicy)).rejects.toThrow() + await expect(completeSnapshotJournalSqliteGeneration(k, policy as typeof journalReceiptPolicy)).rejects.toThrow() + expect(queries).toEqual([]) + } finally { + await k.destroy() + } +}) + +test('generation validates the receipt floor and capacity with bounded metadata reads', async () => { + const { k, source } = await fixture(), + policy = { receiptLimit: 2, receiptLifetimeMs: 1000 } + try { + await installSnapshotJournalSqliteGeneration(k, ceiling, policy) + const baseline = await k('transactions') + for (const floor of ['01', '9223372036854775807']) { + await k('snapshot_journal_retention').update({ floor }) + await expect(readSnapshotJournalSqliteGeneration(k, policy)).rejects.toThrow() + } + await k('snapshot_journal_retention').update({ floor: '0' }) + await k('snapshot_journal_receipts').insert( + [1, 2, 3].map(id => ({ + requestId: id.toString(16).padStart(64, '0'), + binding: 'a'.repeat(64), + highWater: '1', + floor: '0', + expiresAt: 1 + })) + ) + await expect(readSnapshotJournalSqliteGeneration(k, policy)).rejects.toThrow('Invalid or unowned') + await k('snapshot_journal_receipts').where('requestId', '3'.padStart(64, '0')).delete() + expect((await readSnapshotJournalSqliteGeneration(k, policy)).enabled).toBe(true) + expect(await k('transactions')).toEqual(baseline) + } finally { + await source.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts index 10d05a159..fd15bce41 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts @@ -1,3 +1,8 @@ +import { + snapshotJournalReceiptDdl, + snapshotJournalReceiptPolicy, + type SnapshotJournalReceiptPolicy +} from './SnapshotJournalReceipt' import type { Knex } from 'knex' import { createHash, randomUUID } from 'node:crypto' import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' @@ -53,6 +58,7 @@ async function plan(k: Knex, config?: Knex.MigratorConfig): Promise { SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL, ...SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL, SNAPSHOT_JOURNAL_BOOTSTRAP_DDL, + ...snapshotJournalReceiptDdl(k), ...(await snapshotJournalSqliteObserverSql(k)) ] const objects = ddl.map(sql => { @@ -93,7 +99,11 @@ async function reserved(k: Knex): Promise { .orderBy(['type', 'name']) .limit(513) } -async function validate(k: Knex, p: Plan): Promise { +async function validate( + k: Knex, + p: Plan, + policy: SnapshotJournalReceiptPolicy +): Promise { const actual = await reserved(k) if (actual.length !== p.objects.length) return invalid() for (const expected of p.objects) { @@ -149,6 +159,21 @@ async function validate(k: Knex, p: Plan): Promise 0 + ) + return invalid() + const receipts = await k('snapshot_journal_receipts') + .select(k.raw('1 AS occupied')) + .limit(policy.receiptLimit + 1) + if (receipts.length > policy.receiptLimit) return invalid() return { epoch: row.epoch, source: p.source, @@ -162,8 +187,10 @@ async function validate(k: Knex, p: Plan): Promise { + const policy = snapshotJournalReceiptPolicy(receiptPolicy) if (!local(k) || snapshotJournalRevision(ceiling) === '0') return invalid() return await k.transaction(async t => { if ((await readGenerationIndexState(t, config)) !== 'v2') return invalid() @@ -173,7 +200,7 @@ export async function installSnapshotJournalSqliteGeneration( const p = await plan(t, config), existing = await reserved(t) if (existing.length) { - const current = await validate(t, p) + const current = await validate(t, p, policy) if (current.ceiling !== ceiling) return invalid() return current } @@ -197,30 +224,35 @@ export async function installSnapshotJournalSqliteGeneration( reason: null }) await t('snapshot_journal_bootstrap').insert({ id: 1, stream: 0, cursor: null, rowLimit: null, rowsUsed: 0 }) - return await validate(t, p) + await t('snapshot_journal_retention').insert({ id: 1, floor: '0', ...policy }) + return await validate(t, p, policy) }) } /** Validate in the caller's pinned view; migration publication remains a separate prerequisite. */ export async function readSnapshotJournalSqliteGeneration( k: Knex, + receiptPolicy: SnapshotJournalReceiptPolicy, config?: Knex.MigratorConfig ): Promise { - return await validate(k, await plan(k, config)) + const policy = snapshotJournalReceiptPolicy(receiptPolicy) + return await validate(k, await plan(k, config), policy) } /** Atomic completion cannot be inferred from a caller's last-page acknowledgement. */ export async function completeSnapshotJournalSqliteGeneration( k: Knex, + receiptPolicy: SnapshotJournalReceiptPolicy, config?: Knex.MigratorConfig ): Promise { + const policy = snapshotJournalReceiptPolicy(receiptPolicy) if (!local(k)) return invalid() return await k.transaction(async t => { await t(indexMetadata) .where('id', 0) .update({ complete: t.ref('complete') }) const p = await plan(t, config), - state = await validate(t, p) + state = await validate(t, p, policy) const progress = await t('snapshot_journal_bootstrap').select('*').limit(2) const clock = await t('snapshot_journal_clock').where('id', 1).first('enabled') if ( diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json index 268b92854..31d5c1a04 100644 --- a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json @@ -1,241 +1,249 @@ { - "epoch": "3ad6e281-3fd7-4e7a-821a-1646b798c5a9", + "epoch": "338b08b7-6fd5-4369-a892-06aa53896066", "ddl": [ { "sql": "CREATE TABLE snapshot_journal_generation(id INTEGER NOT NULL PRIMARY KEY,version INTEGER NOT NULL,epoch VARCHAR(36) NOT NULL,source VARCHAR(64) NOT NULL,plan VARCHAR(64) NOT NULL,ceiling VARCHAR(19) NOT NULL,nextObject INTEGER NOT NULL,complete BOOLEAN NOT NULL) ENGINE=InnoDB DEFAULT CHARACTER SET ascii COLLATE ascii_bin ROW_FORMAT=DYNAMIC SELECT 1 id,1 version,? epoch,? source,? plan,? ceiling,0 nextObject,0 complete", "bindings": [ - "3ad6e281-3fd7-4e7a-821a-1646b798c5a9", + "338b08b7-6fd5-4369-a892-06aa53896066", "166b30f8f3e2c27b0bdc36a22b1aa86514cd245a3ad0ce84f69c215890a3b2ee", - "9c9f29b167d81462c2b863dd8bf76927f56fdca4f2e6b207bf6d8e6ffd3df373", + "8d32f91f5cf64eb06b842835b18ebd86121a90388efa3e219cbddb71c0320309", "9223372036854775807" ] }, { - "sql": "CREATE TABLE snapshot_journal_clock(id INTEGER NOT NULL PRIMARY KEY,ceiling BIGINT UNSIGNED NOT NULL,CHECK(id=1)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9' SELECT 1 id,? ceiling", + "sql": "CREATE TABLE snapshot_journal_clock(id INTEGER NOT NULL PRIMARY KEY,ceiling BIGINT UNSIGNED NOT NULL,CHECK(id=1)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066' SELECT 1 id,? ceiling", "bindings": ["9223372036854775807"] }, { - "sql": "CREATE TABLE snapshot_journal_events(revision BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9'", + "sql": "CREATE TABLE snapshot_journal_events(revision BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066'", "bindings": [] }, { - "sql": "CREATE TABLE snapshot_journal_invalid(id INTEGER NOT NULL PRIMARY KEY,reason VARCHAR(32) NOT NULL,CHECK(id=1),CHECK(reason IN ('capacity-exhausted','revision-exhausted','key-out-of-range'))) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9'", + "sql": "CREATE TABLE snapshot_journal_invalid(id INTEGER NOT NULL PRIMARY KEY,reason VARCHAR(32) NOT NULL,CHECK(id=1),CHECK(reason IN ('capacity-exhausted','revision-exhausted','key-out-of-range'))) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066'", "bindings": [] }, { - "sql": "CREATE TABLE snapshot_journal_physical(tableId INT NOT NULL,id1 BIGINT UNSIGNED NOT NULL,id2 BIGINT UNSIGNED NOT NULL,exactText VARBINARY(400) NOT NULL,revision BIGINT UNSIGNED NOT NULL,generation BIGINT UNSIGNED NOT NULL,present BOOLEAN NOT NULL,PRIMARY KEY(tableId,id1,id2,exactText),KEY snapshot_journal_physical_page(tableId,revision,id1,id2,exactText)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9'", + "sql": "CREATE TABLE snapshot_journal_physical(tableId INT NOT NULL,id1 BIGINT UNSIGNED NOT NULL,id2 BIGINT UNSIGNED NOT NULL,exactText VARBINARY(400) NOT NULL,revision BIGINT UNSIGNED NOT NULL,generation BIGINT UNSIGNED NOT NULL,present BOOLEAN NOT NULL,PRIMARY KEY(tableId,id1,id2,exactText),KEY snapshot_journal_physical_page(tableId,revision,id1,id2,exactText)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066'", "bindings": [] }, { - "sql": "CREATE TABLE snapshot_journal_scope(tableId INT NOT NULL,userId BIGINT UNSIGNED NOT NULL,id1 BIGINT UNSIGNED NOT NULL,id2 BIGINT UNSIGNED NOT NULL,exactText VARBINARY(400) NOT NULL,revision BIGINT UNSIGNED NOT NULL,present BOOLEAN NOT NULL,PRIMARY KEY(tableId,userId,id1,id2,exactText),KEY snapshot_journal_scope_page(userId,tableId,revision,id1,id2,exactText)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9'", + "sql": "CREATE TABLE snapshot_journal_scope(tableId INT NOT NULL,userId BIGINT UNSIGNED NOT NULL,id1 BIGINT UNSIGNED NOT NULL,id2 BIGINT UNSIGNED NOT NULL,exactText VARBINARY(400) NOT NULL,revision BIGINT UNSIGNED NOT NULL,present BOOLEAN NOT NULL,PRIMARY KEY(tableId,userId,id1,id2,exactText),KEY snapshot_journal_scope_page(userId,tableId,revision,id1,id2,exactText)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066'", "bindings": [] }, { - "sql": "CREATE TABLE snapshot_journal_bootstrap(id INTEGER NOT NULL PRIMARY KEY,stream INTEGER NOT NULL,`cursor` TEXT,rowLimit INTEGER,rowsUsed INTEGER NOT NULL,CHECK(id=1),CHECK(stream BETWEEN 0 AND 17),CHECK(rowLimit IS NULL OR rowLimit BETWEEN 0 AND 2147483647),CHECK(rowsUsed BETWEEN 0 AND 2147483647),CHECK(rowLimit IS NULL OR rowsUsed<=rowLimit)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9' SELECT 1 id,0 stream,NULL `cursor`,NULL rowLimit,0 rowsUsed", + "sql": "CREATE TABLE snapshot_journal_bootstrap(id INTEGER NOT NULL PRIMARY KEY,stream INTEGER NOT NULL,`cursor` TEXT,rowLimit INTEGER,rowsUsed INTEGER NOT NULL,CHECK(id=1),CHECK(stream BETWEEN 0 AND 17),CHECK(rowLimit IS NULL OR rowLimit BETWEEN 0 AND 2147483647),CHECK(rowsUsed BETWEEN 0 AND 2147483647),CHECK(rowLimit IS NULL OR rowsUsed<=rowLimit)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066' SELECT 1 id,0 stream,NULL `cursor`,NULL rowLimit,0 rowsUsed", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_0_INSERT AFTER INSERT ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TABLE snapshot_journal_retention(id INTEGER NOT NULL PRIMARY KEY,floor VARCHAR(19) NOT NULL,receiptLimit INTEGER NOT NULL,receiptLifetimeMs BIGINT NOT NULL,CHECK(id=1),CHECK(receiptLimit BETWEEN 1 AND 128),CHECK(receiptLifetimeMs BETWEEN 1 AND 2592000000)) ENGINE=InnoDB DEFAULT CHARACTER SET ascii COLLATE ascii_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066' SELECT 1 id,'0' floor,? receiptLimit,? receiptLifetimeMs", + "bindings": [128, 2592000000] + }, + { + "sql": "CREATE TABLE snapshot_journal_receipts(requestId VARCHAR(64) NOT NULL PRIMARY KEY,binding VARCHAR(64) NOT NULL,highWater VARCHAR(19) NOT NULL,floor VARCHAR(19) NOT NULL,expiresAt BIGINT NOT NULL,CHECK(expiresAt BETWEEN 1 AND 9007199254740991),KEY snapshot_journal_receipts_expiry(expiresAt,requestId)) ENGINE=InnoDB DEFAULT CHARACTER SET ascii COLLATE ascii_bin ROW_FORMAT=DYNAMIC COMMENT='snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066'", + "bindings": [] + }, + { + "sql": "CREATE TRIGGER snapshot_journal_scope_0_INSERT AFTER INSERT ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_0_DELETE AFTER DELETE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_0_DELETE AFTER DELETE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_0_UPDATE AFTER UPDATE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_0_UPDATE AFTER UPDATE ON `snapshot_profile_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_1_INSERT AFTER INSERT ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_1_INSERT AFTER INSERT ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_1_DELETE AFTER DELETE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_1_DELETE AFTER DELETE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_1_UPDATE AFTER UPDATE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_1_UPDATE AFTER UPDATE ON `snapshot_relation_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_2_INSERT AFTER INSERT ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_2_INSERT AFTER INSERT ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_2_DELETE AFTER DELETE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_2_DELETE AFTER DELETE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_2_UPDATE AFTER UPDATE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_2_UPDATE AFTER UPDATE ON `snapshot_certificate_field_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_3_INSERT AFTER INSERT ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_3_INSERT AFTER INSERT ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_3_DELETE AFTER DELETE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_3_DELETE AFTER DELETE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_scope_3_UPDATE AFTER UPDATE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_scope_3_UPDATE AFTER UPDATE ON `snapshot_global_keys` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_0_INSERT AFTER INSERT ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_0_INSERT AFTER INSERT ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_0_UPDATE AFTER UPDATE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_0_UPDATE AFTER UPDATE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_0_DELETE AFTER DELETE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_0_DELETE AFTER DELETE ON `transactions` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_1_INSERT AFTER INSERT ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_1_INSERT AFTER INSERT ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_1_UPDATE AFTER UPDATE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_1_UPDATE AFTER UPDATE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_1_DELETE AFTER DELETE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_1_DELETE AFTER DELETE ON `outputs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_2_INSERT AFTER INSERT ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_2_INSERT AFTER INSERT ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_2_UPDATE AFTER UPDATE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_2_UPDATE AFTER UPDATE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_2_DELETE AFTER DELETE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_2_DELETE AFTER DELETE ON `certificates` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_3_INSERT AFTER INSERT ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_3_INSERT AFTER INSERT ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_3_UPDATE AFTER UPDATE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_3_UPDATE AFTER UPDATE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_3_DELETE AFTER DELETE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_3_DELETE AFTER DELETE ON `tx_labels` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_4_INSERT AFTER INSERT ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_4_INSERT AFTER INSERT ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_4_UPDATE AFTER UPDATE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_4_UPDATE AFTER UPDATE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_4_DELETE AFTER DELETE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_4_DELETE AFTER DELETE ON `output_baskets` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_5_INSERT AFTER INSERT ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_5_INSERT AFTER INSERT ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_5_UPDATE AFTER UPDATE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_5_UPDATE AFTER UPDATE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_5_DELETE AFTER DELETE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_5_DELETE AFTER DELETE ON `output_tags` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_6_INSERT AFTER INSERT ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_6_INSERT AFTER INSERT ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_6_UPDATE AFTER UPDATE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_6_UPDATE AFTER UPDATE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_6_DELETE AFTER DELETE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_6_DELETE AFTER DELETE ON `commissions` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_7_INSERT AFTER INSERT ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_7_INSERT AFTER INSERT ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_7_UPDATE AFTER UPDATE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_7_UPDATE AFTER UPDATE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_7_DELETE AFTER DELETE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_7_DELETE AFTER DELETE ON `sync_states` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_8_INSERT AFTER INSERT ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_8_INSERT AFTER INSERT ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_8_UPDATE AFTER UPDATE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_8_UPDATE AFTER UPDATE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_8_DELETE AFTER DELETE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_8_DELETE AFTER DELETE ON `proven_txs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_9_INSERT AFTER INSERT ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_9_INSERT AFTER INSERT ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_9_UPDATE AFTER UPDATE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_9_UPDATE AFTER UPDATE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_9_DELETE AFTER DELETE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_9_DELETE AFTER DELETE ON `proven_tx_reqs` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_10_INSERT AFTER INSERT ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_10_INSERT AFTER INSERT ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_10_UPDATE AFTER UPDATE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_10_UPDATE AFTER UPDATE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_10_DELETE AFTER DELETE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_10_DELETE AFTER DELETE ON `tx_labels_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_11_INSERT AFTER INSERT ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_11_INSERT AFTER INSERT ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_11_UPDATE AFTER UPDATE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_11_UPDATE AFTER UPDATE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_11_DELETE AFTER DELETE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_11_DELETE AFTER DELETE ON `output_tags_map` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_12_INSERT AFTER INSERT ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_12_INSERT AFTER INSERT ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_12_UPDATE AFTER UPDATE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_12_UPDATE AFTER UPDATE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "bindings": [] }, { - "sql": "CREATE TRIGGER snapshot_journal_physical_12_DELETE AFTER DELETE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "sql": "CREATE TRIGGER snapshot_journal_physical_12_DELETE AFTER DELETE ON `certificate_fields` FOR EACH ROW BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "bindings": [] } ] diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json index a3bc65d94..01b946a94 100644 --- a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-metadata-fixture.json @@ -3654,53 +3654,53 @@ ], "rows": [ { - "name": "snapshot_profile_0_delete", - "table": "transactions", - "event": "DELETE", + "name": "snapshot_certificate_field_insert", + "table": "certificate_fields", + "event": "INSERT", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; END" + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END" }, { - "name": "snapshot_profile_0_update", - "table": "transactions", + "name": "snapshot_certificate_field_before_update", + "table": "certificate_fields", "event": "UPDATE", - "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END IF; END" + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END IF; END" }, { - "name": "snapshot_profile_0_insert", - "table": "transactions", - "event": "INSERT", + "name": "snapshot_certificate_field_after_update", + "table": "certificate_fields", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END" }, { - "name": "snapshot_profile_1_delete", - "table": "outputs", + "name": "snapshot_certificate_field_delete", + "table": "certificate_fields", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; END" + "body": "BEGIN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END" }, { - "name": "snapshot_profile_1_update", - "table": "outputs", - "event": "UPDATE", + "name": "snapshot_profile_2_insert", + "table": "certificates", + "event": "INSERT", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END IF; END" + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END" }, { - "name": "snapshot_profile_1_insert", - "table": "outputs", + "name": "snapshot_certificate_parent_insert", + "table": "certificates", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END" + "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END" }, { - "name": "snapshot_profile_2_delete", + "name": "snapshot_certificate_parent_before_update", "table": "certificates", - "event": "DELETE", - "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; END" + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END IF; END" }, { "name": "snapshot_profile_2_update", @@ -3710,46 +3710,46 @@ "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END IF; END" }, { - "name": "snapshot_profile_2_insert", + "name": "snapshot_certificate_parent_after_update", "table": "certificates", - "event": "INSERT", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (2, NEW.userId, NEW.certificateId); END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END" }, { - "name": "snapshot_profile_3_delete", - "table": "tx_labels", + "name": "snapshot_profile_2_delete", + "table": "certificates", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; END" + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 2 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.certificateId; END" }, { - "name": "snapshot_profile_3_update", - "table": "tx_labels", - "event": "UPDATE", + "name": "snapshot_certificate_parent_delete", + "table": "certificates", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txLabelId <=> NEW.txLabelId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END IF; END" + "body": "BEGIN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END" }, { - "name": "snapshot_profile_3_insert", - "table": "tx_labels", + "name": "snapshot_profile_6_insert", + "table": "commissions", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END" + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END" }, { - "name": "snapshot_profile_4_delete", - "table": "output_baskets", - "event": "DELETE", + "name": "snapshot_profile_6_update", + "table": "commissions", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.commissionId <=> NEW.commissionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END IF; END" }, { - "name": "snapshot_profile_4_update", - "table": "output_baskets", - "event": "UPDATE", + "name": "snapshot_profile_6_delete", + "table": "commissions", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.basketId <=> NEW.basketId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END IF; END" + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; END" }, { "name": "snapshot_profile_4_insert", @@ -3759,18 +3759,18 @@ "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END" }, { - "name": "snapshot_profile_5_delete", - "table": "output_tags", - "event": "DELETE", + "name": "snapshot_profile_4_update", + "table": "output_baskets", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.basketId <=> NEW.basketId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (4, NEW.userId, NEW.basketId); END IF; END" }, { - "name": "snapshot_profile_5_update", - "table": "output_tags", - "event": "UPDATE", + "name": "snapshot_profile_4_delete", + "table": "output_baskets", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputTagId <=> NEW.outputTagId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END IF; END" + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 4 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.basketId; END" }, { "name": "snapshot_profile_5_insert", @@ -3780,116 +3780,116 @@ "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END" }, { - "name": "snapshot_profile_6_delete", - "table": "commissions", - "event": "DELETE", + "name": "snapshot_relation_1_left_insert", + "table": "output_tags", + "event": "INSERT", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; END" + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" }, { - "name": "snapshot_profile_6_update", - "table": "commissions", + "name": "snapshot_relation_1_left_before_update", + "table": "output_tags", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END IF; END" + }, + { + "name": "snapshot_profile_5_update", + "table": "output_tags", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.commissionId <=> NEW.commissionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 6 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.commissionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END IF; END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputTagId <=> NEW.outputTagId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (5, NEW.userId, NEW.outputTagId); END IF; END" }, { - "name": "snapshot_profile_6_insert", - "table": "commissions", - "event": "INSERT", + "name": "snapshot_relation_1_left_after_update", + "table": "output_tags", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (6, NEW.userId, NEW.commissionId); END" + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" }, { - "name": "snapshot_profile_7_delete", - "table": "sync_states", + "name": "snapshot_profile_5_delete", + "table": "output_tags", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; END" + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 5 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputTagId; END" }, { - "name": "snapshot_profile_7_update", - "table": "sync_states", - "event": "UPDATE", + "name": "snapshot_relation_1_left_delete", + "table": "output_tags", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.syncStateId <=> NEW.syncStateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END IF; END" + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END" }, { - "name": "snapshot_profile_7_insert", - "table": "sync_states", + "name": "snapshot_relation_1_map_insert", + "table": "output_tags_map", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END" + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" }, { - "name": "snapshot_relation_0_map_delete", - "table": "tx_labels_map", - "event": "DELETE", - "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END" + "name": "snapshot_relation_1_map_before_update", + "table": "output_tags_map", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END IF; END" }, { - "name": "snapshot_relation_0_map_before_update", - "table": "tx_labels_map", + "name": "snapshot_relation_1_map_after_update", + "table": "output_tags_map", "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END IF; END" + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" }, { - "name": "snapshot_relation_0_left_delete", - "table": "tx_labels", + "name": "snapshot_relation_1_map_delete", + "table": "output_tags_map", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END" + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END" }, { - "name": "snapshot_relation_0_left_before_update", - "table": "tx_labels", - "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END IF; END" + "name": "snapshot_profile_1_insert", + "table": "outputs", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END" }, { - "name": "snapshot_relation_0_right_delete", - "table": "transactions", - "event": "DELETE", + "name": "snapshot_relation_1_right_insert", + "table": "outputs", + "event": "INSERT", "timing": "AFTER", - "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END" + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" }, { - "name": "snapshot_relation_0_right_before_update", - "table": "transactions", + "name": "snapshot_relation_1_right_before_update", + "table": "outputs", "event": "UPDATE", "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END IF; END" + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END IF; END" }, { - "name": "snapshot_relation_1_map_delete", - "table": "output_tags_map", - "event": "DELETE", + "name": "snapshot_profile_1_update", + "table": "outputs", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (1, NEW.userId, NEW.outputId); END IF; END" }, { - "name": "snapshot_relation_1_map_before_update", - "table": "output_tags_map", + "name": "snapshot_relation_1_right_after_update", + "table": "outputs", "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotLeftId = OLD.outputTagId AND snapshotRightId = OLD.outputId; END IF; END" + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" }, { - "name": "snapshot_relation_1_left_delete", - "table": "output_tags", + "name": "snapshot_profile_1_delete", + "table": "outputs", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END" - }, - { - "name": "snapshot_relation_1_left_before_update", - "table": "output_tags", - "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.outputTagId AND snapshotMembership = 0; END IF; END" + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.outputId; END" }, { "name": "snapshot_relation_1_right_delete", @@ -3899,165 +3899,165 @@ "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END" }, { - "name": "snapshot_relation_1_right_before_update", - "table": "outputs", - "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 1 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.outputId AND snapshotMembership = 0; END IF; END" - }, - { - "name": "snapshot_relation_0_map_insert", - "table": "tx_labels_map", + "name": "snapshot_global_req_insert", + "table": "proven_tx_reqs", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + "body": "BEGIN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END" }, { - "name": "snapshot_relation_0_map_after_update", - "table": "tx_labels_map", + "name": "snapshot_global_req_before_update", + "table": "proven_tx_reqs", "event": "UPDATE", - "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END IF; END" }, { - "name": "snapshot_relation_0_left_insert", - "table": "tx_labels", - "event": "INSERT", + "name": "snapshot_global_req_after_update", + "table": "proven_tx_reqs", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" + "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" }, { - "name": "snapshot_relation_0_left_after_update", - "table": "tx_labels", - "event": "UPDATE", + "name": "snapshot_global_req_delete", + "table": "proven_tx_reqs", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" + "body": "BEGIN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END" }, { - "name": "snapshot_relation_0_right_insert", - "table": "transactions", + "name": "snapshot_global_proof_insert", + "table": "proven_txs", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END" }, { - "name": "snapshot_relation_0_right_after_update", - "table": "transactions", + "name": "snapshot_global_proof_before_update", + "table": "proven_txs", "event": "UPDATE", - "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END IF; END" }, { - "name": "snapshot_relation_1_map_insert", - "table": "output_tags_map", - "event": "INSERT", + "name": "snapshot_global_proof_after_update", + "table": "proven_txs", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" }, { - "name": "snapshot_relation_1_map_after_update", - "table": "output_tags_map", - "event": "UPDATE", + "name": "snapshot_global_proof_delete", + "table": "proven_txs", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.outputId <=> NEW.outputId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 1 FROM output_tags WHERE outputTagId = NEW.outputTagId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, userId, NEW.outputTagId, NEW.outputId, 2 FROM outputs WHERE outputId = NEW.outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END" }, { - "name": "snapshot_relation_1_left_insert", - "table": "output_tags", + "name": "snapshot_global_edge_insert", + "table": "snapshot_global_edges", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" + "body": "BEGIN INSERT INTO snapshot_global_keys (tableId,userId,rowId,refs,present) VALUES (NEW.tableId,NEW.userId,NEW.rowId,1,CASE WHEN NEW.tableId=0 THEN 1 ELSE (SELECT present FROM snapshot_global_guards WHERE proofId=NEW.rowId FOR SHARE) END) ON DUPLICATE KEY UPDATE refs=snapshot_global_keys.refs+1; END" }, { - "name": "snapshot_relation_1_left_after_update", - "table": "output_tags", - "event": "UPDATE", + "name": "snapshot_global_edge_delete", + "table": "snapshot_global_edges", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.outputTagId <=> NEW.outputTagId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 1 FROM output_tags_map WHERE outputTagId = NEW.outputTagId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" + "body": "BEGIN UPDATE snapshot_global_keys SET refs=refs-1 WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId; DELETE FROM snapshot_global_keys WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId AND refs=0; DELETE FROM snapshot_global_guards WHERE proofId=OLD.rowId AND OLD.tableId=1 AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.rowId); END" }, { - "name": "snapshot_relation_1_right_insert", - "table": "outputs", + "name": "snapshot_profile_7_insert", + "table": "sync_states", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END" }, { - "name": "snapshot_relation_1_right_after_update", - "table": "outputs", + "name": "snapshot_profile_7_update", + "table": "sync_states", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.outputId <=> NEW.outputId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 1, NEW.userId, outputTagId, outputId, 2 FROM output_tags_map WHERE outputId = NEW.outputId ORDER BY outputTagId, outputId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.syncStateId <=> NEW.syncStateId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (7, NEW.userId, NEW.syncStateId); END IF; END" }, { - "name": "snapshot_certificate_field_delete", - "table": "certificate_fields", + "name": "snapshot_profile_7_delete", + "table": "sync_states", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END" + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 7 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.syncStateId; END" }, { - "name": "snapshot_certificate_field_before_update", - "table": "certificate_fields", - "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN DELETE FROM snapshot_certificate_field_keys WHERE snapshotFieldName = OLD.fieldName AND snapshotCertificateId = OLD.certificateId; END IF; END" + "name": "snapshot_profile_0_insert", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END" }, { - "name": "snapshot_certificate_parent_delete", - "table": "certificates", - "event": "DELETE", + "name": "snapshot_relation_0_right_insert", + "table": "transactions", + "event": "INSERT", "timing": "AFTER", - "body": "BEGIN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END" + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" }, { - "name": "snapshot_certificate_parent_before_update", - "table": "certificates", + "name": "snapshot_global_tx_insert", + "table": "transactions", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END" + }, + { + "name": "snapshot_relation_0_right_before_update", + "table": "transactions", "event": "UPDATE", "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN UPDATE snapshot_certificate_field_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId; DELETE FROM snapshot_certificate_field_keys WHERE snapshotUserId = OLD.userId AND snapshotCertificateId = OLD.certificateId AND snapshotMembership = 0; END IF; END" + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END IF; END" }, { - "name": "snapshot_certificate_field_insert", - "table": "certificate_fields", - "event": "INSERT", - "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END" + "name": "snapshot_global_tx_before_update", + "table": "transactions", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END IF; END" }, { - "name": "snapshot_certificate_field_after_update", - "table": "certificate_fields", + "name": "snapshot_profile_0_update", + "table": "transactions", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY)) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) VALUES(NEW.userId, NEW.fieldName, NEW.certificateId, 1) ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 1; INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT userId, NEW.fieldName, NEW.certificateId, 2 FROM certificates WHERE certificateId = NEW.certificateId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (0, NEW.userId, NEW.transactionId); END IF; END" }, { - "name": "snapshot_certificate_parent_insert", - "table": "certificates", - "event": "INSERT", + "name": "snapshot_relation_0_right_after_update", + "table": "transactions", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END" + "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 2 FROM tx_labels_map WHERE transactionId = NEW.transactionId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" }, { - "name": "snapshot_certificate_parent_after_update", - "table": "certificates", + "name": "snapshot_global_tx_after_update", + "table": "transactions", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.certificateId <=> NEW.certificateId) THEN INSERT INTO snapshot_certificate_field_keys (snapshotUserId, snapshotFieldName, snapshotCertificateId, snapshotMembership) SELECT NEW.userId, f.fieldName, f.certificateId, 2 FROM snapshot_certificate_field_keys k JOIN certificate_fields f ON f.fieldName = k.snapshotFieldName AND f.certificateId = k.snapshotCertificateId WHERE k.snapshotCertificateId = NEW.certificateId AND (k.snapshotMembership & 1) = 1 ORDER BY k.snapshotFieldName, k.snapshotUserId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshot_certificate_field_keys.snapshotMembership | 2; END IF; END" + "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END IF; END" }, { - "name": "snapshot_global_edge_delete", - "table": "snapshot_global_edges", + "name": "snapshot_profile_0_delete", + "table": "transactions", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN UPDATE snapshot_global_keys SET refs=refs-1 WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId; DELETE FROM snapshot_global_keys WHERE tableId=OLD.tableId AND userId=OLD.userId AND rowId=OLD.rowId AND refs=0; DELETE FROM snapshot_global_guards WHERE proofId=OLD.rowId AND OLD.tableId=1 AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.rowId); END" + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.transactionId; END" }, { - "name": "snapshot_global_edge_insert", - "table": "snapshot_global_edges", - "event": "INSERT", + "name": "snapshot_relation_0_right_delete", + "table": "transactions", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_global_keys (tableId,userId,rowId,refs,present) VALUES (NEW.tableId,NEW.userId,NEW.rowId,1,CASE WHEN NEW.tableId=0 THEN 1 ELSE (SELECT present FROM snapshot_global_guards WHERE proofId=NEW.rowId FOR SHARE) END) ON DUPLICATE KEY UPDATE refs=snapshot_global_keys.refs+1; END" + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 1 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotRightId = OLD.transactionId AND snapshotMembership = 0; END" }, { "name": "snapshot_global_tx_delete", @@ -4067,81 +4067,81 @@ "body": "BEGIN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END" }, { - "name": "snapshot_global_tx_before_update", - "table": "transactions", - "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE transactionId=OLD.transactionId; END IF; END" + "name": "snapshot_profile_3_insert", + "table": "tx_labels", + "event": "INSERT", + "timing": "AFTER", + "body": "BEGIN INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END" }, { - "name": "snapshot_global_req_delete", - "table": "proven_tx_reqs", - "event": "DELETE", + "name": "snapshot_relation_0_left_insert", + "table": "tx_labels", + "event": "INSERT", "timing": "AFTER", - "body": "BEGIN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END" + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END" }, { - "name": "snapshot_global_req_before_update", - "table": "proven_tx_reqs", + "name": "snapshot_relation_0_left_before_update", + "table": "tx_labels", "event": "UPDATE", "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN DELETE FROM snapshot_global_edges WHERE requestId=OLD.provenTxReqId; END IF; END" + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END IF; END" }, { - "name": "snapshot_global_proof_delete", - "table": "proven_txs", - "event": "DELETE", + "name": "snapshot_profile_3_update", + "table": "tx_labels", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END" + "body": "BEGIN IF NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txLabelId <=> NEW.txLabelId) THEN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; INSERT INTO snapshot_profile_keys (snapshotTableId, snapshotUserId, snapshotRowId) VALUES (3, NEW.userId, NEW.txLabelId); END IF; END" }, { - "name": "snapshot_global_proof_before_update", - "table": "proven_txs", + "name": "snapshot_relation_0_left_after_update", + "table": "tx_labels", "event": "UPDATE", - "timing": "BEFORE", - "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (OLD.provenTxId,0) ON DUPLICATE KEY UPDATE present=0; UPDATE snapshot_global_keys SET present=0 WHERE tableId=1 AND rowId=OLD.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=OLD.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=OLD.provenTxId); END IF; END" + "timing": "AFTER", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.userId <=> NEW.userId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, NEW.userId, txLabelId, transactionId, 1 FROM tx_labels_map WHERE txLabelId = NEW.txLabelId ORDER BY txLabelId, transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; END IF; END" }, { - "name": "snapshot_global_proof_insert", - "table": "proven_txs", - "event": "INSERT", + "name": "snapshot_profile_3_delete", + "table": "tx_labels", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END" + "body": "BEGIN DELETE FROM snapshot_profile_keys WHERE snapshotTableId = 3 AND snapshotUserId = OLD.userId AND snapshotRowId = OLD.txLabelId; END" }, { - "name": "snapshot_global_proof_after_update", - "table": "proven_txs", - "event": "UPDATE", + "name": "snapshot_relation_0_left_delete", + "table": "tx_labels", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.provenTxId <=> NEW.provenTxId) THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,1) ON DUPLICATE KEY UPDATE present=1; UPDATE snapshot_global_keys SET present=1 WHERE tableId=1 AND rowId=NEW.provenTxId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" + "body": "BEGIN UPDATE snapshot_relation_keys SET snapshotMembership = snapshotMembership & 2 WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId; DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotUserId = OLD.userId AND snapshotLeftId = OLD.txLabelId AND snapshotMembership = 0; END" }, { - "name": "snapshot_global_tx_insert", - "table": "transactions", + "name": "snapshot_relation_0_map_insert", + "table": "tx_labels_map", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END" + "body": "BEGIN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END" }, { - "name": "snapshot_global_tx_after_update", - "table": "transactions", - "event": "UPDATE", - "timing": "AFTER", - "body": "BEGIN DECLARE requestedId INT UNSIGNED DEFAULT NULL; DECLARE requestedProof INT UNSIGNED DEFAULT NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND BEGIN SET requestedId=NULL; SET requestedProof=NULL; END; IF NOT (OLD.transactionId <=> NEW.transactionId) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,0,1,NEW.provenTxId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; SELECT provenTxReqId,provenTxId INTO requestedId,requestedProof FROM proven_tx_reqs WHERE txid=NEW.txid FOR SHARE; IF requestedId IS NOT NULL THEN INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,0,requestedId,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; IF requestedProof IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (requestedProof,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=requestedProof; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) VALUES (NEW.transactionId,requestedId,1,requestedProof,NEW.userId) ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; END IF; END IF; END IF; END" + "name": "snapshot_relation_0_map_before_update", + "table": "tx_labels_map", + "event": "UPDATE", + "timing": "BEFORE", + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END IF; END" }, { - "name": "snapshot_global_req_insert", - "table": "proven_tx_reqs", - "event": "INSERT", + "name": "snapshot_relation_0_map_after_update", + "table": "tx_labels_map", + "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END" + "body": "BEGIN IF NOT (OLD.txLabelId <=> NEW.txLabelId) OR NOT (OLD.transactionId <=> NEW.transactionId) THEN INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 1 FROM tx_labels WHERE txLabelId = NEW.txLabelId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 1; INSERT INTO snapshot_relation_keys (snapshotTableId, snapshotUserId, snapshotLeftId, snapshotRightId, snapshotMembership) SELECT 0, userId, NEW.txLabelId, NEW.transactionId, 2 FROM transactions WHERE transactionId = NEW.transactionId FOR SHARE ON DUPLICATE KEY UPDATE snapshotMembership = snapshotMembership | 2; END IF; END" }, { - "name": "snapshot_global_req_after_update", - "table": "proven_tx_reqs", - "event": "UPDATE", + "name": "snapshot_relation_0_map_delete", + "table": "tx_labels_map", + "event": "DELETE", "timing": "AFTER", - "body": "BEGIN IF NOT (OLD.provenTxReqId <=> NEW.provenTxReqId) OR NOT (OLD.txid <=> NEW.txid) OR NOT (OLD.provenTxId <=> NEW.provenTxId) THEN IF NEW.provenTxId IS NOT NULL THEN INSERT INTO snapshot_global_guards (proofId,present) VALUES (NEW.provenTxId,0) ON DUPLICATE KEY UPDATE proofId=snapshot_global_guards.proofId; UPDATE snapshot_global_guards g LEFT JOIN proven_txs p ON p.provenTxId=g.proofId SET g.present=(p.provenTxId IS NOT NULL) WHERE g.proofId=NEW.provenTxId; END IF; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,0,NEW.provenTxReqId,userId FROM transactions WHERE txid=NEW.txid ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; INSERT INTO snapshot_global_edges (transactionId,requestId,tableId,rowId,userId) SELECT transactionId,NEW.provenTxReqId,1,NEW.provenTxId,userId FROM transactions WHERE txid=NEW.txid AND NEW.provenTxId IS NOT NULL ORDER BY transactionId FOR SHARE ON DUPLICATE KEY UPDATE transactionId = snapshot_global_edges.transactionId; DELETE FROM snapshot_global_guards WHERE proofId=NEW.provenTxId AND NOT EXISTS(SELECT 1 FROM snapshot_global_keys WHERE tableId=1 AND rowId=NEW.provenTxId); END IF; END" + "body": "BEGIN DELETE FROM snapshot_relation_keys WHERE snapshotTableId = 0 AND snapshotLeftId = OLD.txLabelId AND snapshotRightId = OLD.transactionId; END" } ] }, @@ -10293,6 +10293,14 @@ "name": "snapshot_journal_physical", "type": "BASE TABLE" }, + { + "name": "snapshot_journal_receipts", + "type": "BASE TABLE" + }, + { + "name": "snapshot_journal_retention", + "type": "BASE TABLE" + }, { "name": "snapshot_journal_scope", "type": "BASE TABLE" @@ -10649,7 +10657,7 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" + "comment": "snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066" } ] }, @@ -10739,7 +10747,7 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" + "comment": "snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066" } ] }, @@ -10813,7 +10821,7 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" + "comment": "snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066" } ] }, @@ -10909,7 +10917,7 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" + "comment": "snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066" } ] }, @@ -11131,7 +11139,7 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" + "comment": "snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066" } ] }, @@ -11375,7 +11383,7 @@ "collation": "utf8mb4_bin", "rowFormat": "Dynamic", "options": "row_format=DYNAMIC", - "comment": "snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9" + "comment": "snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066" } ] }, @@ -11509,6 +11517,270 @@ "bindings": ["snapshot_journal_bootstrap"], "rows": [] }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": ["snapshot_journal_retention"], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "ascii_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC", + "comment": "snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": ["snapshot_journal_retention"], + "rows": [ + { + "name": "id", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "floor", + "type": "varchar(19)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "receiptLimit", + "type": "int", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + }, + { + "name": "receiptLifetimeMs", + "type": "bigint", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", + "bindings": ["snapshot_journal_retention"], + "rows": [ + { + "name": "PRIMARY", + "columnName": "id", + "position": 1, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", + "bindings": ["snapshot_journal_retention"], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES", + "clause": null + }, + { + "name": "snapshot_journal_retention_chk_1", + "type": "CHECK", + "enforced": "YES", + "clause": "(`id` = 1)" + }, + { + "name": "snapshot_journal_retention_chk_2", + "type": "CHECK", + "enforced": "YES", + "clause": "(`receiptLimit` between 1 and 128)" + }, + { + "name": "snapshot_journal_retention_chk_3", + "type": "CHECK", + "enforced": "YES", + "clause": "(`receiptLifetimeMs` between 1 and 2592000000)" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": ["snapshot_journal_retention"], + "rows": [] + }, + { + "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", + "bindings": ["snapshot_journal_retention"], + "rows": [] + }, + { + "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", + "bindings": ["snapshot_journal_retention"], + "rows": [] + }, + { + "sql": "SELECT ENGINE engine,TABLE_TYPE type,TABLE_COLLATION collation,ROW_FORMAT rowFormat,CREATE_OPTIONS options,TABLE_COMMENT comment FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", + "bindings": ["snapshot_journal_receipts"], + "rows": [ + { + "engine": "InnoDB", + "type": "BASE TABLE", + "collation": "ascii_bin", + "rowFormat": "Dynamic", + "options": "row_format=DYNAMIC", + "comment": "snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066" + } + ] + }, + { + "sql": "SELECT COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,COLUMN_DEFAULT defaultValue,EXTRA extra,CHARACTER_SET_NAME charset,COLLATION_NAME collation,GENERATION_EXPRESSION expression FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION LIMIT 9", + "bindings": ["snapshot_journal_receipts"], + "rows": [ + { + "name": "requestId", + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "binding", + "type": "varchar(64)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "highWater", + "type": "varchar(19)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "floor", + "type": "varchar(19)", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": "ascii", + "collation": "ascii_bin", + "expression": "" + }, + { + "name": "expiresAt", + "type": "bigint", + "nullable": "NO", + "defaultValue": null, + "extra": "", + "charset": null, + "collation": null, + "expression": "" + } + ] + }, + { + "sql": "SELECT INDEX_NAME name,COLUMN_NAME columnName,SEQ_IN_INDEX position,NON_UNIQUE nonUnique,COLLATION direction,SUB_PART prefix,INDEX_TYPE type,IS_VISIBLE visible,EXPRESSION expression FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX LIMIT 16", + "bindings": ["snapshot_journal_receipts"], + "rows": [ + { + "name": "PRIMARY", + "columnName": "requestId", + "position": 1, + "nonUnique": 0, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_receipts_expiry", + "columnName": "expiresAt", + "position": 1, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + }, + { + "name": "snapshot_journal_receipts_expiry", + "columnName": "requestId", + "position": 2, + "nonUnique": 1, + "direction": "A", + "prefix": null, + "type": "BTREE", + "visible": "YES", + "expression": null + } + ] + }, + { + "sql": "SELECT t.CONSTRAINT_NAME name,t.CONSTRAINT_TYPE type,t.ENFORCED enforced,c.CHECK_CLAUSE clause FROM information_schema.TABLE_CONSTRAINTS t LEFT JOIN information_schema.CHECK_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=t.CONSTRAINT_NAME WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=? ORDER BY t.CONSTRAINT_NAME LIMIT 8", + "bindings": ["snapshot_journal_receipts"], + "rows": [ + { + "name": "PRIMARY", + "type": "PRIMARY KEY", + "enforced": "YES", + "clause": null + }, + { + "name": "snapshot_journal_receipts_chk_1", + "type": "CHECK", + "enforced": "YES", + "clause": "(`expiresAt` between 1 and 9007199254740991)" + } + ] + }, + { + "sql": "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=? LIMIT 1", + "bindings": ["snapshot_journal_receipts"], + "rows": [] + }, + { + "sql": "SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND PARTITION_NAME IS NOT NULL LIMIT 1", + "bindings": ["snapshot_journal_receipts"], + "rows": [] + }, + { + "sql": "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=? LIMIT 1", + "bindings": ["snapshot_journal_receipts"], + "rows": [] + }, { "sql": "SELECT TRIGGER_NAME name,EVENT_OBJECT_TABLE `table`,EVENT_MANIPULATION event,ACTION_TIMING timing,SUBSTRING(ACTION_STATEMENT,1,?) body,SQL_MODE sqlMode,CHARACTER_SET_CLIENT charset,COLLATION_CONNECTION collation,DATABASE_COLLATION databaseCollation,DEFINER definer FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME=?", "bindings": [1444, "snapshot_journal_scope_0_INSERT"], @@ -11518,7 +11790,7 @@ "table": "snapshot_profile_keys", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11536,7 +11808,7 @@ "table": "snapshot_profile_keys", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId,OLD.snapshotUserId,OLD.snapshotRowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11554,7 +11826,7 @@ "table": "snapshot_profile_keys", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId <=> NEW.snapshotTableId) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotRowId <=> NEW.snapshotRowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (1 <=> 1) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotRowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId,NEW.snapshotUserId,NEW.snapshotRowId,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11572,7 +11844,7 @@ "table": "snapshot_relation_keys", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11590,7 +11862,7 @@ "table": "snapshot_relation_keys", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (OLD.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT OLD.snapshotTableId+10,OLD.snapshotUserId,OLD.snapshotLeftId,OLD.snapshotRightId,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11608,7 +11880,7 @@ "table": "snapshot_relation_keys", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (OLD.snapshotTableId+10 <=> NEW.snapshotTableId+10) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotLeftId <=> NEW.snapshotLeftId) OR NOT (OLD.snapshotRightId <=> NEW.snapshotRightId) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotLeftId BETWEEN 1 AND 9007199254740991) AND (NEW.snapshotRightId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT NEW.snapshotTableId+10,NEW.snapshotUserId,NEW.snapshotLeftId,NEW.snapshotRightId,CAST('' AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11626,7 +11898,7 @@ "table": "snapshot_certificate_field_keys", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11644,7 +11916,7 @@ "table": "snapshot_certificate_field_keys", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.snapshotFieldName AS BINARY))<=400) AND (OLD.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,OLD.snapshotUserId,OLD.snapshotCertificateId,0,CAST(OLD.snapshotFieldName AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11662,7 +11934,7 @@ "table": "snapshot_certificate_field_keys", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (12 <=> 12) OR NOT (OLD.snapshotUserId <=> NEW.snapshotUserId) OR NOT (OLD.snapshotCertificateId <=> NEW.snapshotCertificateId) OR NOT (0 <=> 0) OR NOT (CAST(OLD.snapshotFieldName AS BINARY) <=> CAST(NEW.snapshotFieldName AS BINARY)) OR NOT ((OLD.snapshotMembership<>0) <=> (NEW.snapshotMembership<>0)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.snapshotCertificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.snapshotFieldName AS BINARY))<=400) AND (NEW.snapshotUserId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,NEW.snapshotUserId,NEW.snapshotCertificateId,0,CAST(NEW.snapshotFieldName AS BINARY),journalRevision,(NEW.snapshotMembership<>0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11680,7 +11952,7 @@ "table": "snapshot_global_keys", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11698,7 +11970,7 @@ "table": "snapshot_global_keys", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END,OLD.userId,OLD.rowId,0,CAST('' AS BINARY),journalRevision,0 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11716,7 +11988,7 @@ "table": "snapshot_global_keys", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CASE OLD.tableId WHEN 0 THEN 9 ELSE 8 END <=> CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END) OR NOT (OLD.userId <=> NEW.userId) OR NOT (OLD.rowId <=> NEW.rowId) OR NOT (0 <=> 0) OR NOT (CAST('' AS BINARY) <=> CAST('' AS BINARY)) OR NOT (OLD.present <=> NEW.present) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.rowId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT CASE NEW.tableId WHEN 0 THEN 9 ELSE 8 END,NEW.userId,NEW.rowId,0,CAST('' AS BINARY),journalRevision,NEW.present ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11734,7 +12006,7 @@ "table": "transactions", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11752,7 +12024,7 @@ "table": "transactions", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`reference` AS BINARY) <=> CAST(NEW.`reference` AS BINARY)) OR NOT (CAST(OLD.`isOutgoing` AS BINARY) <=> CAST(NEW.`isOutgoing` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`version` AS BINARY) <=> CAST(NEW.`version` AS BINARY)) OR NOT (CAST(OLD.`lockTime` AS BINARY) <=> CAST(NEW.`lockTime` AS BINARY)) OR NOT (CAST(OLD.`description` AS BINARY) <=> CAST(NEW.`description` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryMode` AS BINARY) <=> CAST(NEW.`noSendExpiryMode` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryValue` AS BINARY) <=> CAST(NEW.`noSendExpiryValue` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryDeadline` AS BINARY) <=> CAST(NEW.`noSendExpiryDeadline` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryState` AS BINARY) <=> CAST(NEW.`noSendExpiryState` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryAnchorVout` AS BINARY) <=> CAST(NEW.`noSendExpiryAnchorVout` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReleasedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryReleasedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryObservedAt` AS BINARY) <=> CAST(NEW.`noSendExpiryObservedAt` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimTxid` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimTxid` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimRawTx` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimRawTx` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationPrefix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimDerivationSuffix` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimDerivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`noSendExpiryReclaimSatoshis` AS BINARY) <=> CAST(NEW.`noSendExpiryReclaimSatoshis` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`transactionId` <=> NEW.`transactionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 0,NEW.userId,NEW.`transactionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11770,7 +12042,7 @@ "table": "transactions", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`transactionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(0,OLD.`transactionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11788,7 +12060,7 @@ "table": "outputs", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11806,7 +12078,7 @@ "table": "outputs", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`spendable` AS BINARY) <=> CAST(NEW.`spendable` AS BINARY)) OR NOT (CAST(OLD.`change` AS BINARY) <=> CAST(NEW.`change` AS BINARY)) OR NOT (CAST(OLD.`vout` AS BINARY) <=> CAST(NEW.`vout` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`providedBy` AS BINARY) <=> CAST(NEW.`providedBy` AS BINARY)) OR NOT (CAST(OLD.`purpose` AS BINARY) <=> CAST(NEW.`purpose` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`outputDescription` AS BINARY) <=> CAST(NEW.`outputDescription` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`senderIdentityKey` AS BINARY) <=> CAST(NEW.`senderIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`derivationPrefix` AS BINARY) <=> CAST(NEW.`derivationPrefix` AS BINARY)) OR NOT (CAST(OLD.`derivationSuffix` AS BINARY) <=> CAST(NEW.`derivationSuffix` AS BINARY)) OR NOT (CAST(OLD.`customInstructions` AS BINARY) <=> CAST(NEW.`customInstructions` AS BINARY)) OR NOT (CAST(OLD.`spentBy` AS BINARY) <=> CAST(NEW.`spentBy` AS BINARY)) OR NOT (CAST(OLD.`sequenceNumber` AS BINARY) <=> CAST(NEW.`sequenceNumber` AS BINARY)) OR NOT (CAST(OLD.`spendingDescription` AS BINARY) <=> CAST(NEW.`spendingDescription` AS BINARY)) OR NOT (CAST(OLD.`scriptLength` AS BINARY) <=> CAST(NEW.`scriptLength` AS BINARY)) OR NOT (CAST(OLD.`scriptOffset` AS BINARY) <=> CAST(NEW.`scriptOffset` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputId` <=> NEW.`outputId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 1,NEW.userId,NEW.`outputId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11824,7 +12096,7 @@ "table": "outputs", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(1,OLD.`outputId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11842,7 +12114,7 @@ "table": "certificates", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11860,7 +12132,7 @@ "table": "certificates", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`serialNumber` AS BINARY) <=> CAST(NEW.`serialNumber` AS BINARY)) OR NOT (CAST(OLD.`type` AS BINARY) <=> CAST(NEW.`type` AS BINARY)) OR NOT (CAST(OLD.`certifier` AS BINARY) <=> CAST(NEW.`certifier` AS BINARY)) OR NOT (CAST(OLD.`subject` AS BINARY) <=> CAST(NEW.`subject` AS BINARY)) OR NOT (CAST(OLD.`verifier` AS BINARY) <=> CAST(NEW.`verifier` AS BINARY)) OR NOT (CAST(OLD.`revocationOutpoint` AS BINARY) <=> CAST(NEW.`revocationOutpoint` AS BINARY)) OR NOT (CAST(OLD.`signature` AS BINARY) <=> CAST(NEW.`signature` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`certificateId` <=> NEW.`certificateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 2,NEW.userId,NEW.`certificateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11878,7 +12150,7 @@ "table": "certificates", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`certificateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(2,OLD.`certificateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11896,7 +12168,7 @@ "table": "tx_labels", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11914,7 +12186,7 @@ "table": "tx_labels", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`label` AS BINARY) <=> CAST(NEW.`label` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`txLabelId` <=> NEW.`txLabelId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 3,NEW.userId,NEW.`txLabelId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11932,7 +12204,7 @@ "table": "tx_labels", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`txLabelId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(3,OLD.`txLabelId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11950,7 +12222,7 @@ "table": "output_baskets", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11968,7 +12240,7 @@ "table": "output_baskets", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`basketId` AS BINARY) <=> CAST(NEW.`basketId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`name` AS BINARY) <=> CAST(NEW.`name` AS BINARY)) OR NOT (CAST(OLD.`numberOfDesiredUTXOs` AS BINARY) <=> CAST(NEW.`numberOfDesiredUTXOs` AS BINARY)) OR NOT (CAST(OLD.`minimumDesiredUTXOValue` AS BINARY) <=> CAST(NEW.`minimumDesiredUTXOValue` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`basketId` <=> NEW.`basketId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 4,NEW.userId,NEW.`basketId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -11986,7 +12258,7 @@ "table": "output_baskets", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`basketId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(4,OLD.`basketId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12004,7 +12276,7 @@ "table": "output_tags", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12022,7 +12294,7 @@ "table": "output_tags", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`tag` AS BINARY) <=> CAST(NEW.`tag` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`outputTagId` <=> NEW.`outputTagId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 5,NEW.userId,NEW.`outputTagId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12040,7 +12312,7 @@ "table": "output_tags", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`outputTagId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(5,OLD.`outputTagId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12058,7 +12330,7 @@ "table": "commissions", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12076,7 +12348,7 @@ "table": "commissions", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`commissionId` AS BINARY) <=> CAST(NEW.`commissionId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`keyOffset` AS BINARY) <=> CAST(NEW.`keyOffset` AS BINARY)) OR NOT (CAST(OLD.`isRedeemed` AS BINARY) <=> CAST(NEW.`isRedeemed` AS BINARY)) OR NOT (CAST(OLD.`lockingScript` AS BINARY) <=> CAST(NEW.`lockingScript` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`commissionId` <=> NEW.`commissionId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 6,NEW.userId,NEW.`commissionId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12094,7 +12366,7 @@ "table": "commissions", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`commissionId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(6,OLD.`commissionId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12112,7 +12384,7 @@ "table": "sync_states", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12130,7 +12402,7 @@ "table": "sync_states", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`syncStateId` AS BINARY) <=> CAST(NEW.`syncStateId` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`storageIdentityKey` AS BINARY) <=> CAST(NEW.`storageIdentityKey` AS BINARY)) OR NOT (CAST(OLD.`storageName` AS BINARY) <=> CAST(NEW.`storageName` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`init` AS BINARY) <=> CAST(NEW.`init` AS BINARY)) OR NOT (CAST(OLD.`refNum` AS BINARY) <=> CAST(NEW.`refNum` AS BINARY)) OR NOT (CAST(OLD.`syncMap` AS BINARY) <=> CAST(NEW.`syncMap` AS BINARY)) OR NOT (CAST(OLD.`when` AS BINARY) <=> CAST(NEW.`when` AS BINARY)) OR NOT (CAST(OLD.`satoshis` AS BINARY) <=> CAST(NEW.`satoshis` AS BINARY)) OR NOT (CAST(OLD.`errorLocal` AS BINARY) <=> CAST(NEW.`errorLocal` AS BINARY)) OR NOT (CAST(OLD.`errorOther` AS BINARY) <=> CAST(NEW.`errorOther` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (NEW.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`syncStateId` <=> NEW.`syncStateId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 7,NEW.userId,NEW.`syncStateId`,0,CAST('' AS BINARY),journalRevision,1 ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12148,7 +12420,7 @@ "table": "sync_states", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`syncStateId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400) AND (OLD.userId BETWEEN 1 AND 9007199254740991)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(7,OLD.`syncStateId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12166,7 +12438,7 @@ "table": "proven_txs", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12184,7 +12456,7 @@ "table": "proven_txs", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`height` AS BINARY) <=> CAST(NEW.`height` AS BINARY)) OR NOT (CAST(OLD.`index` AS BINARY) <=> CAST(NEW.`index` AS BINARY)) OR NOT (CAST(OLD.`merklePath` AS BINARY) <=> CAST(NEW.`merklePath` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`blockHash` AS BINARY) <=> CAST(NEW.`blockHash` AS BINARY)) OR NOT (CAST(OLD.`merkleRoot` AS BINARY) <=> CAST(NEW.`merkleRoot` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,NEW.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxId` <=> NEW.`provenTxId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12202,7 +12474,7 @@ "table": "proven_txs", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(8,OLD.`provenTxId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12220,7 +12492,7 @@ "table": "proven_tx_reqs", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12238,7 +12510,7 @@ "table": "proven_tx_reqs", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`provenTxReqId` AS BINARY) <=> CAST(NEW.`provenTxReqId` AS BINARY)) OR NOT (CAST(OLD.`provenTxId` AS BINARY) <=> CAST(NEW.`provenTxId` AS BINARY)) OR NOT (CAST(OLD.`status` AS BINARY) <=> CAST(NEW.`status` AS BINARY)) OR NOT (CAST(OLD.`attempts` AS BINARY) <=> CAST(NEW.`attempts` AS BINARY)) OR NOT (CAST(OLD.`notified` AS BINARY) <=> CAST(NEW.`notified` AS BINARY)) OR NOT (CAST(OLD.`txid` AS BINARY) <=> CAST(NEW.`txid` AS BINARY)) OR NOT (CAST(OLD.`batch` AS BINARY) <=> CAST(NEW.`batch` AS BINARY)) OR NOT (CAST(OLD.`history` AS BINARY) <=> CAST(NEW.`history` AS BINARY)) OR NOT (CAST(OLD.`notify` AS BINARY) <=> CAST(NEW.`notify` AS BINARY)) OR NOT (CAST(OLD.`rawTx` AS BINARY) <=> CAST(NEW.`rawTx` AS BINARY)) OR NOT (CAST(OLD.`inputBEEF` AS BINARY) <=> CAST(NEW.`inputBEEF` AS BINARY)) OR NOT (CAST(OLD.`wasBroadcast` AS BINARY) <=> CAST(NEW.`wasBroadcast` AS BINARY)) OR NOT (CAST(OLD.`rebroadcastAttempts` AS BINARY) <=> CAST(NEW.`rebroadcastAttempts` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,NEW.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.`provenTxReqId` <=> NEW.`provenTxReqId`) AND (0 <=> 0) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12256,7 +12528,7 @@ "table": "proven_tx_reqs", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.`provenTxReqId` BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(9,OLD.`provenTxReqId`,0,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12274,7 +12546,7 @@ "table": "tx_labels_map", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12292,7 +12564,7 @@ "table": "tx_labels_map", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`txLabelId` AS BINARY) <=> CAST(NEW.`txLabelId` AS BINARY)) OR NOT (CAST(OLD.`transactionId` AS BINARY) <=> CAST(NEW.`transactionId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.txLabelId BETWEEN 1 AND 9007199254740991) AND (NEW.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,NEW.txLabelId,NEW.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.txLabelId <=> NEW.txLabelId) AND (OLD.transactionId <=> NEW.transactionId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 10,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=0 AND snapshotLeftId=NEW.txLabelId AND snapshotRightId=NEW.transactionId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12310,7 +12582,7 @@ "table": "tx_labels_map", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.txLabelId BETWEEN 1 AND 9007199254740991) AND (OLD.transactionId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(10,OLD.txLabelId,OLD.transactionId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12328,7 +12600,7 @@ "table": "output_tags_map", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12346,7 +12618,7 @@ "table": "output_tags_map", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`outputTagId` AS BINARY) <=> CAST(NEW.`outputTagId` AS BINARY)) OR NOT (CAST(OLD.`outputId` AS BINARY) <=> CAST(NEW.`outputId` AS BINARY)) OR NOT (CAST(OLD.`isDeleted` AS BINARY) <=> CAST(NEW.`isDeleted` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.outputTagId BETWEEN 1 AND 9007199254740991) AND (NEW.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,NEW.outputTagId,NEW.outputId,CAST('' AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.outputTagId <=> NEW.outputTagId) AND (OLD.outputId <=> NEW.outputId) AND (CAST('' AS BINARY) <=> CAST('' AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 11,snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=1 AND snapshotLeftId=NEW.outputTagId AND snapshotRightId=NEW.outputId AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12364,7 +12636,7 @@ "table": "output_tags_map", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.outputTagId BETWEEN 1 AND 9007199254740991) AND (OLD.outputId BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST('' AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(11,OLD.outputTagId,OLD.outputId,CAST('' AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12382,7 +12654,7 @@ "table": "certificate_fields", "event": "INSERT", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN TRUE THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12400,7 +12672,7 @@ "table": "certificate_fields", "event": "UPDATE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF NOT (CAST(OLD.`created_at` AS BINARY) <=> CAST(NEW.`created_at` AS BINARY)) OR NOT (CAST(OLD.`updated_at` AS BINARY) <=> CAST(NEW.`updated_at` AS BINARY)) OR NOT (CAST(OLD.`userId` AS BINARY) <=> CAST(NEW.`userId` AS BINARY)) OR NOT (CAST(OLD.`certificateId` AS BINARY) <=> CAST(NEW.`certificateId` AS BINARY)) OR NOT (CAST(OLD.`fieldName` AS BINARY) <=> CAST(NEW.`fieldName` AS BINARY)) OR NOT (CAST(OLD.`fieldValue` AS BINARY) <=> CAST(NEW.`fieldValue` AS BINARY)) OR NOT (CAST(OLD.`masterKey` AS BINARY) <=> CAST(NEW.`masterKey` AS BINARY)) THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((NEW.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(NEW.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN IF NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,NEW.certificateId,0,CAST(NEW.fieldName AS BINARY),journalRevision,journalRevision,1) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN NOT ((OLD.certificateId <=> NEW.certificateId) AND (0 <=> 0) AND (CAST(OLD.fieldName AS BINARY) <=> CAST(NEW.fieldName AS BINARY))) THEN VALUES(generation) ELSE generation END,present=VALUES(present); INSERT INTO snapshot_journal_scope(tableId,userId,id1,id2,exactText,revision,present) SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", @@ -12418,7 +12690,7 @@ "table": "certificate_fields", "event": "DELETE", "timing": "AFTER", - "body": "BEGIN /* snapshot-journal-owner:3ad6e281-3fd7-4e7a-821a-1646b798c5a9 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", + "body": "BEGIN /* snapshot-journal-owner:338b08b7-6fd5-4369-a892-06aa53896066 */ DECLARE journalCeiling BIGINT UNSIGNED;\nDECLARE journalRevision BIGINT UNSIGNED;\nDECLARE journalEnabled BOOLEAN; IF TRUE THEN SELECT ceiling INTO journalCeiling FROM snapshot_journal_clock WHERE id=1 FOR SHARE;\nSELECT NOT EXISTS(SELECT 1 FROM snapshot_journal_invalid WHERE id=1) INTO journalEnabled;\nIF journalEnabled THEN\n INSERT INTO snapshot_journal_events() VALUES();\n SET journalRevision=LAST_INSERT_ID();\n DELETE FROM snapshot_journal_events WHERE revision=journalRevision;\n IF journalRevision>journalCeiling THEN\n INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,IF(journalRevision>9223372036854775807,'revision-exhausted','capacity-exhausted'));\n SET journalEnabled=FALSE;\n END IF;\nEND IF; IF NOT COALESCE(((OLD.certificateId BETWEEN 1 AND 9007199254740991) AND (0 BETWEEN 0 AND 9007199254740991) AND (OCTET_LENGTH(CAST(OLD.fieldName AS BINARY))<=400)),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; IF journalEnabled THEN INSERT INTO snapshot_journal_physical(tableId,id1,id2,exactText,revision,generation,present) VALUES(12,OLD.certificateId,0,CAST(OLD.fieldName AS BINARY),journalRevision,journalRevision,0) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN FALSE THEN VALUES(generation) ELSE generation END,present=VALUES(present); END IF; END IF; END", "sqlMode": "IGNORE_SPACE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", diff --git a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json index 5687a3c4a..5fd168ef9 100644 --- a/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json +++ b/packages/wallet/wallet-toolbox/test/fixtures/snapshotJournal/mysql-generation-state-fixture.json @@ -1,10 +1,10 @@ { "id": 1, "version": 1, - "epoch": "3ad6e281-3fd7-4e7a-821a-1646b798c5a9", + "epoch": "338b08b7-6fd5-4369-a892-06aa53896066", "source": "166b30f8f3e2c27b0bdc36a22b1aa86514cd245a3ad0ce84f69c215890a3b2ee", - "plan": "9c9f29b167d81462c2b863dd8bf76927f56fdca4f2e6b207bf6d8e6ffd3df373", + "plan": "8d32f91f5cf64eb06b842835b18ebd86121a90388efa3e219cbddb71c0320309", "ceiling": "9223372036854775807", - "nextObject": 57, + "nextObject": 59, "complete": 1 } diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs index 9250eed6d..d1ad79b88 100644 --- a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs @@ -6,7 +6,7 @@ const { join } = require('node:path') const assert = require('node:assert/strict') const { executable, context, image, validateContext, validateContainer } = require('./snapshotArchiveDocker.cjs') const { runInSeries } = require('../../out/src/utility/runInSeries.js') -const journalFixtureGroups = Object.freeze(['generation', 'server-crash']) +const journalFixtureGroups = Object.freeze(['generation', 'server-crash', 'receipts']) const execute = (file, args, options) => new Promise((resolve, reject) => { execFile( @@ -109,7 +109,14 @@ async function runFixture(group) { const result = await execute( process.execPath, [ - join(__dirname, group === 'generation' ? 'snapshotJournalMysql.cjs' : 'snapshotJournalMysqlServerCrash.cjs'), + join( + __dirname, + group === 'generation' + ? 'snapshotJournalMysql.cjs' + : group === 'server-crash' + ? 'snapshotJournalMysqlServerCrash.cjs' + : 'snapshotJournalReceiptMysql.cjs' + ), group ], { @@ -121,7 +128,7 @@ async function runFixture(group) { TS_STACK_SNAPSHOT_MYSQL_SECRET: secret }, signal: cancellation.signal, - timeout: group === 'generation' ? 180000 : 240000 + timeout: group === 'generation' ? 180000 : group === 'server-crash' ? 240000 : 60000 } ) process.stdout.write(result) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs index 1d82d18c1..9878cf5e4 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs @@ -1,3 +1,4 @@ +const receiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } const assert = require('node:assert/strict'), { fork } = require('node:child_process'), { mkdtemp, rm } = require('node:fs/promises'), @@ -33,7 +34,7 @@ async function finish(k) { for (let i = 0; i < 100; i++) { const page = await copy(k, 1000000) assert(!page.invalidated) - if (page.complete) return await complete(k, ceiling) + if (page.complete) return await complete(k, ceiling, receiptPolicy) assert(i < 99) } } @@ -84,7 +85,7 @@ async function child() { }) try { await isolate(k, isolation) - await install(k, ceiling) + await install(k, ceiling, receiptPolicy) if (boundary.startsWith('bootstrap-')) { await copy(k, 1000000) park('bootstrap-after-commit') @@ -96,7 +97,7 @@ async function child() { assert(i < 99) } completing = true - await complete(k, ceiling) + await complete(k, ceiling, receiptPolicy) park('complete-after-commit') } throw new Error('Crash boundary missed: ' + boundary) @@ -136,13 +137,13 @@ async function main() { for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) { await isolate(k, isolation) await clear(k) - const created = await install(k, ceiling) - assert.equal(created.nextObject, 57) + const created = await install(k, ceiling, receiptPolicy) + assert.equal(created.nextObject, 59) assert.equal(created.complete, false) assert.equal(created.enabled, true) - assert.deepEqual(await install(k, ceiling), created) - assert.deepEqual(await read(k, ceiling), created) - await assert.rejects(complete(k, ceiling), /Invalid or unowned/) + assert.deepEqual(await install(k, ceiling, receiptPolicy), created) + assert.deepEqual(await read(k, ceiling, receiptPolicy), created) + await assert.rejects(complete(k, ceiling, receiptPolicy), /Invalid or unowned/) const outer = await k.transaction(), independent = open() try { @@ -160,9 +161,9 @@ async function main() { await exact(k) assert.equal(completed.complete, true) assert.equal(completed.epoch, created.epoch) - assert.deepEqual(await read(k, ceiling), completed) - assert.deepEqual(await complete(k, ceiling), completed) - await k.transaction(async t => assert.deepEqual(await read(t, ceiling), completed)) + assert.deepEqual(await read(k, ceiling, receiptPolicy), completed) + assert.deepEqual(await complete(k, ceiling, receiptPolicy), completed) + await k.transaction(async t => assert.deepEqual(await read(t, ceiling, receiptPolicy), completed)) for (const [_name, up, down] of [ [ 'owner-comment', @@ -191,22 +192,22 @@ async function main() { ] ]) { await k.raw(up) - await assert.rejects(read(k, ceiling), /Invalid or unowned/) - await assert.rejects(install(k, ceiling), /Invalid or unowned/) + await assert.rejects(read(k, ceiling, receiptPolicy), /Invalid or unowned/) + await assert.rejects(install(k, ceiling, receiptPolicy), /Invalid or unowned/) await k.raw(down) - assert.deepEqual(await read(k, ceiling), completed) + assert.deepEqual(await read(k, ceiling, receiptPolicy), completed) } - await k(intent).update({ nextObject: 56, complete: 0 }) + await k(intent).update({ nextObject: 58, complete: 0 }) await k.raw('CREATE TABLE snapshot_journal_foreign(id INT)') - await assert.rejects(install(k, ceiling), /Invalid or unowned/) + await assert.rejects(install(k, ceiling, receiptPolicy), /Invalid or unowned/) assert(await k.schema.hasTable('snapshot_journal_foreign')) await k.schema.dropTable('snapshot_journal_foreign') - await k(intent).update({ nextObject: 57, complete: 1 }) + await k(intent).update({ nextObject: 59, complete: 1 }) await k('snapshot_journal_clock').delete() - await assert.rejects(install(k, ceiling), /Invalid or unowned/) + await assert.rejects(install(k, ceiling, receiptPolicy), /Invalid or unowned/) await k('snapshot_journal_clock').insert({ id: 1, ceiling }) await k('snapshot_journal_bootstrap').update({ stream: 16, cursor: null }) - await assert.rejects(read(k, ceiling), /Invalid or unowned/) + await assert.rejects(read(k, ceiling, receiptPolicy), /Invalid or unowned/) await k('snapshot_journal_bootstrap').update({ stream: 17, cursor: null }) await exact(k) assert.deepEqual(await rows(k), baseline) @@ -217,10 +218,14 @@ async function main() { 'before-snapshot_journal_clock', 'after-snapshot_journal_clock', 'after-snapshot_journal_bootstrap', + 'before-snapshot_journal_retention', + 'after-snapshot_journal_retention', + 'before-snapshot_journal_receipts', + 'after-snapshot_journal_receipts', 'after-snapshot_journal_scope_0_INSERT', 'after-ack-7', 'after-snapshot_journal_physical_12_DELETE', - 'after-ack-57', + 'after-ack-59', 'bootstrap-after-budget-bind', 'bootstrap-after-metadata', 'bootstrap-after-progress', @@ -244,7 +249,7 @@ async function main() { : null ) } - const resumed = await install(k, ceiling) + const resumed = await install(k, ceiling, receiptPolicy) if (saved) assert.equal(resumed.epoch, saved.epoch) await finish(k) let charged = 0 @@ -272,7 +277,7 @@ async function main() { } results.push({ isolation, - installedObjects: 58, + installedObjects: 60, exactResumeAndSourcePreserved: true, bootstrapAndObservers: true, refusals: true, diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlConnection.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlConnection.cjs index 7f3927b6f..1d05ee60f 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlConnection.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlConnection.cjs @@ -21,5 +21,14 @@ const connection = { timezone: 'Z' } -const open = () => knex({ client: 'mysql2', connection, pool: { min: 1, max: 1 }, acquireConnectionTimeout: 5000 }) +const open = bigNumberStrings => + knex({ + client: 'mysql2', + connection: { + ...connection, + ...(typeof bigNumberStrings === 'boolean' ? { supportBigNumbers: true, bigNumberStrings } : {}) + }, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 5000 + }) module.exports = { open, ...require('./snapshotJournalNativeFixture.cjs') } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs index a97fe3b18..51133b616 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs @@ -1,3 +1,4 @@ +const receiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } const assert = require('node:assert/strict'), { fork } = require('node:child_process'), { mkdtemp, rm } = require('node:fs/promises'), @@ -91,7 +92,7 @@ async function finish(k) { for (let i = 0; i < 100; i++) { const page = await copy(k, 1000000) assert(!page.invalidated) - if (page.complete) return await complete(k, ceiling) + if (page.complete) return await complete(k, ceiling, receiptPolicy) assert(i < 99) } } @@ -143,7 +144,7 @@ async function child() { }) try { await isolate(k, isolation) - await install(k, ceiling) + await install(k, ceiling, receiptPolicy) if (boundary.startsWith('bootstrap-')) { await copy(k, 1000000) park('bootstrap-after-commit') @@ -155,7 +156,7 @@ async function child() { assert(i < 99) } completing = true - await complete(k, ceiling) + await complete(k, ceiling, receiptPolicy) park('complete-after-commit') } throw new Error('Crash boundary missed: ' + boundary) @@ -201,6 +202,8 @@ async function main() { const boundaries = [ 'after-snapshot_journal_generation', 'after-snapshot_journal_clock', + 'after-snapshot_journal_retention', + 'after-snapshot_journal_receipts', 'after-snapshot_journal_scope_0_INSERT', 'after-snapshot_journal_physical_12_DELETE', 'bootstrap-after-budget-bind', @@ -234,7 +237,7 @@ async function main() { : null ) } - const resumed = await install(k, ceiling) + const resumed = await install(k, ceiling, receiptPolicy) assert.equal(resumed.epoch, saved.epoch) await finish(k) let charged = 0 diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs new file mode 100644 index 000000000..fe25a3303 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs @@ -0,0 +1,248 @@ +const assert = require('node:assert/strict') +const { open } = require('./snapshotJournalMysqlConnection.cjs') +const { + snapshotJournalReceiptDdl, + snapshotJournalReceiptBinding, + recordSnapshotJournalReceipt, + readSnapshotJournalReceipt, + collectSnapshotJournalReceipts +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js') +const binding = { + backend: '11'.repeat(32), + epoch: '12345678-1234-4234-9234-123456789012', + source: '22'.repeat(32), + schema: '33'.repeat(32), + storageIdentity: 'synthetic-storage', + identityKey: '02' + '44'.repeat(32), + userId: 1, + chain: 'test' +} +async function qualify(isolation, bigNumberStrings) { + const k = open(bigNumberStrings), + peer = open(bigNumberStrings) + try { + for (const db of [k, peer]) await db.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + for (const table of ['snapshot_journal_receipts', 'snapshot_journal_retention', 'receipt_fixture_source']) + await k.schema.dropTableIfExists(table) + for (const sql of snapshotJournalReceiptDdl(k)) await k.raw(sql) + await k('snapshot_journal_retention').insert({ id: 1, floor: '0', receiptLimit: 2, receiptLifetimeMs: 2592000000 }) + await k.raw('CREATE TABLE receipt_fixture_source(id INTEGER PRIMARY KEY,value INTEGER NOT NULL) ENGINE=InnoDB') + await k('receipt_fixture_source').insert({ id: 1, value: 0 }) + const input = n => ({ + requestId: n.toString(16).padStart(64, '0'), + highWater: '9007199254740993', + expiresAt: Date.now() + 60000 + }) + const a = input(1), + b = input(2), + c = input(3) + const stored = await k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)) + assert.deepEqual(stored, { ...a, binding: snapshotJournalReceiptBinding(binding), floor: '0' }) + assert.deepEqual(await peer.transaction(t => readSnapshotJournalReceipt(t, binding, a)), stored) + assert.deepEqual(await k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)), stored) + await assert.rejects(k.transaction(t => readSnapshotJournalReceipt(t, { ...binding, userId: 2 }, a))) + await assert.rejects( + k.transaction(async t => { + await recordSnapshotJournalReceipt(t, binding, b) + throw Error('before-commit') + }), + /before-commit/ + ) + assert.equal((await k('snapshot_journal_receipts')).length, 1) + await assert.rejects( + (async () => { + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, b)) + throw Error('lost-ack') + })(), + /lost-ack/ + ) + assert.equal((await peer.transaction(t => readSnapshotJournalReceipt(t, binding, b))).requestId, b.requestId) + await assert.rejects(k.transaction(t => recordSnapshotJournalReceipt(t, binding, c))) + const stale = await peer.transaction() + try { + await stale('snapshot_journal_receipts').select('requestId') + await k('snapshot_journal_receipts').where('requestId', a.requestId).delete() + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, c)) + await assert.rejects(recordSnapshotJournalReceipt(stale, binding, a)) + } finally { + await stale.rollback() + } + await k('snapshot_journal_receipts').where('requestId', c.requestId).delete() + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)) + await k('snapshot_journal_receipts').where('requestId', a.requestId).update({ expiresAt: 1 }) + await assert.rejects(k.transaction(t => recordSnapshotJournalReceipt(t, binding, c))) + assert.equal(await k.transaction(t => collectSnapshotJournalReceipts(t)), 1) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, c)) + const held = await peer.transaction() + try { + await held('snapshot_journal_retention').where('id', 1).forUpdate().first() + const start = performance.now() + await assert.rejects( + k.transaction(t => recordSnapshotJournalReceipt(t, binding, b)), + e => e.code === 'ER_LOCK_NOWAIT' + ) + await assert.rejects( + k.transaction(t => readSnapshotJournalReceipt(t, binding, b)), + e => e.code === 'ER_LOCK_NOWAIT' + ) + assert(performance.now() - start < 1000) + await k('receipt_fixture_source').where('id', 1).update({ value: 1 }) + } finally { + await held.rollback() + } + await k('snapshot_journal_retention').update({ floor: '9007199254740994' }) + await assert.rejects(peer.transaction(t => readSnapshotJournalReceipt(t, binding, b))) + await k('snapshot_journal_retention').update({ floor: '0' }) + await k('snapshot_journal_receipts').delete() + await assert.rejects(peer.transaction(t => readSnapshotJournalReceipt(t, binding, b))) + const rows = Array.from({ length: 128 }, (_, n) => ({ + requestId: n.toString(16).padStart(64, '0'), + binding: snapshotJournalReceiptBinding(binding), + highWater: '9', + floor: '0', + expiresAt: 1 + })) + await k('snapshot_journal_retention').update({ receiptLimit: 128 }) + await k('snapshot_journal_receipts').insert(rows) + assert.equal(await k.transaction(t => collectSnapshotJournalReceipts(t)), 64) + assert.equal(await k.transaction(t => collectSnapshotJournalReceipts(t)), 64) + assert.equal(await k.transaction(t => collectSnapshotJournalReceipts(t)), 0) + return { + isolation, + bigNumberStrings, + exactSigned63: true, + independentRead: true, + immutableRetry: true, + rollbackAndLostAck: true, + boundedCapacityAndCollector: true, + nonwaitingRetentionLock: true, + currentReadAfterOlderSnapshot: true, + independentSourceWrite: true, + collectedOrMissingPrefixRefused: true + } + } finally { + await Promise.all([k.destroy(), peer.destroy()]) + } +} +const { fork } = require('node:child_process'), + { mkdtemp, rm } = require('node:fs/promises'), + { writeFileSync, readFileSync } = require('node:fs'), + { tmpdir } = require('node:os'), + { join } = require('node:path') +const processRequest = { requestId: 'ee'.repeat(32), highWater: '9007199254740993', expiresAt: 0 } +async function crashChild() { + process.once('disconnect', () => process.exit(1)) + const deadline = setTimeout(() => process.exit(1), 15000) + deadline.unref() + const [phase, marker, isolation, expiresAt] = process.argv.slice(3), + k = open() + const die = () => { + writeFileSync(marker, phase) + process.kill(process.pid, 'SIGKILL') + } + try { + assert(['READ COMMITTED', 'REPEATABLE READ'].includes(isolation)) + await k.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + await k.transaction(async t => { + if (phase.startsWith('record')) + await recordSnapshotJournalReceipt(t, binding, { ...processRequest, expiresAt: Number(expiresAt) }) + else assert.equal(await collectSnapshotJournalReceipts(t), 1) + if (phase.endsWith('before-commit')) die() + }) + if (phase.endsWith('after-commit')) die() + throw Error('Unreached owned crash boundary') + } finally { + await k.destroy() + } +} +async function processLoss() { + const k = open(), + directory = await mkdtemp(join(tmpdir(), 'ts569-receipt-loss-')), + results = [] + try { + for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) + for (const phase of [ + 'record-before-commit', + 'record-after-commit', + 'collect-before-commit', + 'collect-after-commit' + ]) { + await k('snapshot_journal_receipts').delete() + await k('snapshot_journal_retention').update({ floor: '0', receiptLimit: 128, receiptLifetimeMs: 2592000000 }) + const request = { ...processRequest, expiresAt: Date.now() + 60000 }, + marker = join(directory, results.length + '.txt') + if (phase.startsWith('collect')) + await k('snapshot_journal_receipts').insert({ + ...request, + binding: snapshotJournalReceiptBinding(binding), + floor: '0', + expiresAt: 1 + }) + const child = fork(__filename, ['child', phase, marker, isolation, String(request.expiresAt)], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + let stderr = '' + child.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-6000) + }) + const timer = setTimeout(() => child.kill('SIGKILL'), 15000) + let terminal + try { + terminal = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + } finally { + clearTimeout(timer) + } + assert.equal(terminal.signal, 'SIGKILL', stderr) + assert.equal(readFileSync(marker, 'utf8'), phase) + const exists = phase === 'record-after-commit' || phase === 'collect-before-commit' + assert.equal((await k('snapshot_journal_receipts')).length, exists ? 1 : 0) + if (phase === 'record-after-commit') + assert.equal( + (await k.transaction(t => readSnapshotJournalReceipt(t, binding, request))).highWater, + request.highWater + ) + if (phase === 'record-before-commit') + await assert.rejects(k.transaction(t => readSnapshotJournalReceipt(t, binding, request))) + await k('receipt_fixture_source') + .where('id', 1) + .update({ value: results.length + 2 }) + results.push({ + isolation, + phase, + signal: terminal.signal, + receiptPersisted: exists, + sourceWriteAfterLoss: true + }) + } + return results + } finally { + await k.destroy() + await rm(directory, { recursive: true, force: true }) + } +} +async function main() { + const results = [] + for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) + for (const bigNumberStrings of [false, true]) results.push(await qualify(isolation, bigNumberStrings)) + const crashes = await processLoss() + console.log( + JSON.stringify({ + status: 'native receipt-store and process-loss draft', + results, + crashes, + productionAdoption: false, + limitations: [ + 'Synthetic receipt transactions do not establish full source-capture ordering', + 'Capture controller, floor/tombstone lifecycle and registered migration remain incomplete', + 'A receipt cannot reopen a killed retained read view' + ] + }) + ) +} +;(process.argv[2] === 'child' ? crashChild() : main()).catch(e => { + console.error(e) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs index 4330e37b5..c671c413b 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs @@ -1,3 +1,4 @@ +const receiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } const assert = require('node:assert/strict'), { fork } = require('node:child_process'), { mkdtemp, rm } = require('node:fs/promises'), @@ -44,6 +45,7 @@ async function child() { if (sql.startsWith('create trigger snapshot_journal_physical_12_delete')) park('install-after-ddl') if (sql.startsWith('insert into `snapshot_journal_generation`')) park('install-after-generation') if (sql.startsWith('insert into `snapshot_journal_bootstrap`')) park('install-after-bootstrap') + if (sql.startsWith('insert into `snapshot_journal_retention`')) park('install-after-retention') if (sql.startsWith('update `snapshot_journal_generation`')) park('complete-after-state') if (sql.startsWith('update `snapshot_journal_bootstrap` set `rowlimit`')) park('bootstrap-after-budget-bind') if (sql.startsWith('insert into `snapshot_journal_physical`')) park('bootstrap-after-metadata') @@ -54,13 +56,13 @@ async function child() { }) try { if (boundary.startsWith('install-')) { - await install(k, '1000000') + await install(k, '1000000', receiptPolicy) park('install-after-commit') } else if (boundary.startsWith('bootstrap-')) { await copy(k, 1000000) park('bootstrap-after-commit') } else { - await complete(k) + await complete(k, receiptPolicy) park('complete-after-commit') } throw new Error('Boundary not reached') @@ -94,6 +96,7 @@ async function main() { 'install-after-ddl', 'install-after-generation', 'install-after-bootstrap', + 'install-after-retention', 'install-after-commit', 'bootstrap-after-budget-bind', 'bootstrap-after-metadata', @@ -115,9 +118,9 @@ async function main() { await seedArchiveClosure(source, user.userId, other.userId) const original = {} for (const table of tables) original[table] = await k(table) - if (boundary.startsWith('bootstrap-')) await install(k, '1000000') + if (boundary.startsWith('bootstrap-')) await install(k, '1000000', receiptPolicy) if (boundary.startsWith('complete-')) { - await install(k, '1000000') + await install(k, '1000000', receiptPolicy) await finish(k) } const killed = await killAt(filename, boundary) @@ -128,9 +131,9 @@ async function main() { }) const objects = await k('sqlite_master').whereRaw('lower(substr(name,1,17))=?', ['snapshot_journal_']) const committed = boundary.endsWith('after-commit') - if (boundary.startsWith('install-')) assert.equal(objects.length, committed ? 58 : 0) + if (boundary.startsWith('install-')) assert.equal(objects.length, committed ? 61 : 0) else if (boundary.startsWith('bootstrap-')) { - assert.equal((await read(k)).complete, false) + assert.equal((await read(k, receiptPolicy)).complete, false) const progress = await k('snapshot_journal_bootstrap').first() assert.equal(progress.rowsUsed, committed ? original.transactions.length : 0) assert.equal(progress.rowLimit, committed ? 1000000 : null) @@ -138,11 +141,11 @@ async function main() { progress.cursor, committed ? JSON.stringify([Math.max(...original.transactions.map(row => row.transactionId))]) : null ) - } else assert.equal((await read(k)).complete, committed) - await install(k, '1000000') + } else assert.equal((await read(k, receiptPolicy)).complete, committed) + await install(k, '1000000', receiptPolicy) await finish(k) - await complete(k) - assert.equal((await read(k)).complete, true) + await complete(k, receiptPolicy) + assert.equal((await read(k, receiptPolicy)).complete, true) await exact(k) let charged = 0 for (const table of [ diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index c704ce294..536f7f43d 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -57,7 +57,8 @@ const plans = new Map([ ['src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts', 'mysql-source'], ['src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', 'sqlite-generation'], ['src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', 'mysql-intent'], - ['src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts', 'mysql-generation'] + ['src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts', 'mysql-generation'], + ['src/storage/snapshot/journal/SnapshotJournalReceipt.ts', 'receipts'] ]) } ], diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 1eeb4bbc3..d502f656c 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -293,7 +293,8 @@ for (const [id, expected, fallback] of [ 'mysql-source', 'sqlite-generation', 'mysql-intent', - 'mysql-generation' + 'mysql-generation', + 'receipts' ], 'revision' ], diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 1e2b04b42..28aa48254 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -256,7 +256,8 @@ test('journal mutation registration retains its complete source, canonical tests 'src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts', 'src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', 'src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', - 'src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts' + 'src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts', + 'src/storage/snapshot/journal/SnapshotJournalReceipt.ts' ]) assert.deepEqual(target.additionalInputs, [ 'test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json', @@ -271,6 +272,7 @@ test('journal mutation registration retains its complete source, canonical tests 'test/storage/snapshotJournalMysqlConnection.cjs', 'test/storage/snapshotJournalMysql.cjs', 'test/storage/snapshotJournalMysqlServerCrash.cjs', + 'test/storage/snapshotJournalReceiptMysql.cjs', 'test/storage/snapshotJournalSqliteCrash.cjs', 'test/storage/runSnapshotJournalMysql.cjs', 'test/storage/snapshotArchiveDocker.cjs' From 5f24f30dc0d0ff63edfe65b80e6d380781adf9b8 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 09:21:39 -0700 Subject: [PATCH 089/127] refactor(wallet): make journal validation and recovery steps explicit --- .../schema/snapshotSqliteLegacyOwnership.ts | 2 +- .../schema/snapshotSqliteMembership.ts | 37 +-- .../snapshot/KnexWalletReadSnapshot.ts | 19 +- .../SnapshotSqliteGenerationRegistry.test.ts | 6 +- .../journal/SnapshotJournal.property.test.ts | 57 ++-- .../journal/SnapshotJournalBootstrap.ts | 286 ++++++++++-------- .../SnapshotJournalMysqlGeneration.test.ts | 12 +- .../journal/SnapshotJournalMysqlGeneration.ts | 56 ++-- .../journal/SnapshotJournalMysqlObservers.ts | 87 +++--- .../journal/SnapshotJournalMysqlSource.ts | 47 +-- .../journal/SnapshotJournalReceipt.ts | 2 +- .../SnapshotJournalReceiptMysql.test.ts | 11 +- .../journal/SnapshotJournalRevision.ts | 2 +- .../SnapshotJournalSqliteGeneration.test.ts | 12 +- .../SnapshotJournalSqliteGeneration.ts | 78 ++--- .../journal/SnapshotJournalSqliteObservers.ts | 67 ++-- .../test/storage/runSnapshotJournalMysql.cjs | 47 ++- .../test/storage/snapshotJournalMysql.cjs | 133 ++++---- .../snapshotJournalMysqlServerCrash.cjs | 84 +++-- .../storage/snapshotJournalReceiptMysql.cjs | 131 ++++---- .../storage/snapshotJournalSqliteCrash.cjs | 173 ++++++----- .../storage/snapshotSqliteGenerationCrash.cjs | 4 +- .../test/utils/snapshotSqliteFixtures.ts | 11 +- .../utils/snapshotSqliteIdentityFixture.ts | 5 +- .../utils/snapshotSqliteMaintenanceFixture.ts | 18 +- 25 files changed, 772 insertions(+), 615 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts index ce66c947c..40b7322dc 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteLegacyOwnership.ts @@ -71,7 +71,7 @@ export async function validateLegacy(k: Knex, config?: Knex.MigratorConfig): Pro if ( !(object.type === 'trigger' && names.has(object.name)) && (retiredTables.includes(object.tbl_name) || - retiredTables.some(table => new RegExp('\\b' + table + '\\b', 'i').test(object.sql))) + retiredTables.some(table => new RegExp(String.raw`\b${table}\b`, 'i').test(object.sql))) ) throw new WERR_INVALID_OPERATION('Unowned object references legacy auxiliary data') return names diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteMembership.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteMembership.ts index 7df0d240b..5f6757627 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteMembership.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteMembership.ts @@ -153,10 +153,9 @@ export function membershipBodies(names: MembershipNames = legacyNames) { function numericMembership(d: IdentityDefinition, event: Event): string { const { table, key, owner } = d.source const context = event === 'DELETE' ? 'OLD' : 'NEW' + const ownerColumn = owner ? ',OLD.' + q(owner) + ' AS ' + q(owner) : '' const retired = - event === 'DELETE' - ? `SELECT OLD.${q(key)} AS ${q(key)}${owner ? ',OLD.' + q(owner) + ' AS ' + q(owner) : ''}` - : displacedIdentity(d, event === 'UPDATE') + event === 'DELETE' ? `SELECT OLD.${q(key)} AS ${q(key)}${ownerColumn}` : displacedIdentity(d, event === 'UPDATE') const ids = `SELECT ${q(key)} FROM (${retired})` const owners = owner ? `SELECT ${q(owner)},${q(key)} FROM (${retired})` : '' const sql = @@ -214,24 +213,28 @@ function changed(columns: string[]): string { .join(' OR ') } -export function membershipTriggers(definitions: IdentityDefinition[], names: MembershipNames = legacyNames): string[] { +function numericTriggers(d: IdentityDefinition, bodies: ReturnType): string[] { const sql: string[] = [] - const bodies = membershipBodies(names) - for (const d of definitions) { - const fields = d.columns.map(column => column.name) - if (d.source.table === 'transactions') fields.push('txid', 'provenTxId') - if (d.source.table === 'proven_tx_reqs') fields.push('provenTxId') - for (const event of ['INSERT', 'UPDATE', 'DELETE'] as const) { - const when = event === 'UPDATE' ? ' WHEN ' + changed(fields) : '' - if (event !== 'DELETE') - sql.push( - `CREATE TRIGGER ${q('snapshot_identity_before_' + d.source.table + '_' + event)} BEFORE ${event} ON ${q(d.source.table)}${when} BEGIN ${observeIdentity(d, event === 'UPDATE')} END` - ) + const fields = d.columns.map(column => column.name) + if (d.source.table === 'transactions') fields.push('txid', 'provenTxId') + if (d.source.table === 'proven_tx_reqs') fields.push('provenTxId') + for (const event of ['INSERT', 'UPDATE', 'DELETE'] as const) { + const when = event === 'UPDATE' ? ' WHEN ' + changed(fields) : '' + if (event !== 'DELETE') sql.push( - `CREATE TRIGGER ${q('snapshot_identity_after_' + d.source.table + '_' + event)} AFTER ${event} ON ${q(d.source.table)}${when} BEGIN ${bodies.numeric(d, event)} END` + `CREATE TRIGGER ${q('snapshot_identity_before_' + d.source.table + '_' + event)} BEFORE ${event} ON ${q(d.source.table)}${when} BEGIN ${observeIdentity(d, event === 'UPDATE')} END` ) - } + sql.push( + `CREATE TRIGGER ${q('snapshot_identity_after_' + d.source.table + '_' + event)} AFTER ${event} ON ${q(d.source.table)}${when} BEGIN ${bodies.numeric(d, event)} END` + ) } + return sql +} + +export function membershipTriggers(definitions: IdentityDefinition[], names: MembershipNames = legacyNames): string[] { + const sql: string[] = [] + const bodies = membershipBodies(names) + for (const d of definitions) sql.push(...numericTriggers(d, bodies)) for (const table of [...relations.map(relation => relation.table), 'certificate_fields']) { const relation = relations.find(relation => relation.table === table) const fields = relation ? [relation.leftKey, relation.rightKey] : ['fieldName', 'certificateId', 'userId'] diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts index d273f5337..0138ebd9e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/KnexWalletReadSnapshot.ts @@ -164,11 +164,10 @@ function relationSourceQuery( // The maintenance index begins with the same profile prefix. MySQL can // choose it and sort the complete profile before LIMIT; bind paging to the // auxiliary primary key whose suffix is the unchanged cursor order. + const sqliteKeys = state === 'v2' ? 'snapshot_relation_keys_v2' : 'snapshot_relation_keys' const relationKeys = String(k.client.config.client).includes('mysql') ? k.raw('?? FORCE INDEX (??)', ['snapshot_relation_keys', 'PRIMARY']) - : state === 'v2' - ? 'snapshot_relation_keys_v2' - : 'snapshot_relation_keys' + : sqliteKeys const query = k(relationKeys) .crossJoin(name, function () { void this.on('snapshotLeftId', '=', `${name}.${left}`).andOn('snapshotRightId', '=', `${name}.${right}`) @@ -184,11 +183,8 @@ function certificateSourceQuery(k: Knex, userId: number, state: SnapshotIndexSta const name = 'certificate_fields' const mysql = String(k.client.config.client).includes('mysql') - const keys = mysql - ? k.raw('?? FORCE INDEX (??)', ['snapshot_certificate_field_keys', 'PRIMARY']) - : state === 'v2' - ? 'snapshot_certificate_field_keys_v2' - : 'snapshot_certificate_field_keys' + const sqliteKeys = state === 'v2' ? 'snapshot_certificate_field_keys_v2' : 'snapshot_certificate_field_keys' + const keys = mysql ? k.raw('?? FORCE INDEX (??)', ['snapshot_certificate_field_keys', 'PRIMARY']) : sqliteKeys const query = k(keys) .crossJoin(name, function () { void this.on('snapshotFieldName', '=', `${name}.fieldName`).andOn( @@ -214,11 +210,8 @@ function globalSourceQuery( const { name } = definitions[table] const mysql = String(k.client.config.client).includes('mysql') - const keys = mysql - ? k.raw('?? FORCE INDEX (??)', ['snapshot_global_keys', 'snapshot_global_page']) - : state === 'v2' - ? 'snapshot_global_keys_v2' - : 'snapshot_global_keys' + const sqliteKeys = state === 'v2' ? 'snapshot_global_keys_v2' : 'snapshot_global_keys' + const keys = mysql ? k.raw('?? FORCE INDEX (??)', ['snapshot_global_keys', 'snapshot_global_page']) : sqliteKeys const query = k(keys) .crossJoin(name, 'rowId', `${name}.${definitions[table].keys[0]}`) .where({ tableId: globalId, userId, present: 1 }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts index 925e57e95..899d01674 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSqliteGenerationRegistry.test.ts @@ -110,9 +110,9 @@ test.each(['complete', 'unpublished'] as const)( test('MySQL retains its existing migration and deletion behavior without SQLite operations', async () => { const database = { client: { config: { client: 'mysql2' } } } as Knex - await migrateGeneration(database) - await refuseGenerationDowngrade(database) - await dropGenerationForDataDeletion(database) + await expect(migrateGeneration(database)).resolves.toBeUndefined() + await expect(refuseGenerationDowngrade(database)).resolves.toBeUndefined() + await expect(dropGenerationForDataDeletion(database)).resolves.toBeUndefined() }) test('empty migration adapters without a dialect label do not issue SQLite cleanup commands', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts index 7035948b8..7e9f3a7e5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts @@ -48,6 +48,11 @@ const propertyParameters = { } fc.configureGlobal(propertyParameters) +function compareBigints(a: bigint, b: bigint): number { + if (a === b) return 0 + return a < b ? -1 : 1 +} + describe('SnapshotJournalRevision', () => { const boundaries = [ '0', @@ -104,7 +109,7 @@ describe('SnapshotJournalRevision', () => { (a, b) => { const left = snapshotJournalRevision(a.toString()), right = snapshotJournalRevision(b.toString()) - expect(compareSnapshotJournalRevisions(left, right)).toBe(a === b ? 0 : a < b ? -1 : 1) + expect(compareSnapshotJournalRevisions(left, right)).toBe(compareBigints(a, b)) expect(compareSnapshotJournalRevisions(left, left)).toBe(0) expect(snapshotJournalRevision(JSON.parse(JSON.stringify(left)))).toBe(left) } @@ -205,7 +210,7 @@ describe('SnapshotJournalPage', () => { } const compare = (a: SnapshotJournalPosition, b: SnapshotJournalPosition) => - (BigInt(a.revision) < BigInt(b.revision) ? -1 : BigInt(a.revision) > BigInt(b.revision) ? 1 : 0) || + compareBigints(BigInt(a.revision), BigInt(b.revision)) || a.id1 - b.id1 || a.id2 - b.id2 || Buffer.compare(Buffer.from(a.exactText), Buffer.from(b.exactText)) @@ -534,6 +539,20 @@ describe('SnapshotJournalSqliteObservers', () => { if (table === 'output_tags_map') return [prefix + '.outputTagId', prefix + '.outputId', "''"] return [prefix + '.certificateId', '0', prefix + '.fieldName'] } + function physicalIdentity(tableId: number, table: string, row: Record) { + const key = numeric[tableId]?.[1] + let id1 = row.certificateId, + id2: unknown = 0 + if (key) id1 = row[key] + else if (table === 'tx_labels_map') { + id1 = row.txLabelId + id2 = row.transactionId + } else if (table === 'output_tags_map') { + id1 = row.outputTagId + id2 = row.outputId + } + return { tableId, id1, id2, exactText: table === 'certificate_fields' ? row.fieldName : '' } + } async function installCandidate(k: Knex, reverse: boolean) { const definitions = await snapshotJournalSqliteObserverSql(k) await installSnapshotJournalSqliteClock(k, snapshotJournalRevision('1000000000')) @@ -808,20 +827,7 @@ describe('SnapshotJournalSqliteObservers', () => { expect((await k('snapshot_journal_clock').first()).revision).toBe(rollbackHigh) for (const [tableId, table] of tables.entries()) { const original = await k(table).first() - const key = numeric[tableId]?.[1] - const identity = { - tableId, - id1: key - ? original[key] - : table === 'certificate_fields' - ? original.certificateId - : table === 'tx_labels_map' - ? original.txLabelId - : original.outputTagId, - id2: - table === 'tx_labels_map' ? original.transactionId : table === 'output_tags_map' ? original.outputId : 0, - exactText: table === 'certificate_fields' ? original.fieldName : '' - } + const identity = physicalIdentity(tableId, table, original) const before = await k('snapshot_journal_physical').where(identity).first() await replace(k, table, original) await exact(k) @@ -1065,7 +1071,7 @@ test('bounded receipt schedules preserve exact committed identity, expiry, floor const owner = { ...binding, userId: command.other ? 2 : 1 } const digest = Receipt.snapshotJournalReceiptBinding(owner) const prior = model.get(request.requestId) - if (command.kind === 0) { + async function recordCommand() { const valid = request.expiresAt > clock && high >= floor && @@ -1081,13 +1087,15 @@ test('bounded receipt schedules preserve exact committed identity, expiry, floor await expect(operation).resolves.toEqual(prior ?? result) if (prior === undefined) model.set(request.requestId, result) } - } else if (command.kind === 1) { + } + async function readCommand() { const valid = prior !== undefined && prior.binding === digest && request.expiresAt > clock && high >= floor const operation = k.transaction(t => Receipt.readSnapshotJournalReceipt(t, owner, request)) if (valid) await expect(operation).resolves.toEqual(prior) else await expect(operation).rejects.toThrow() - } else if (command.kind === 2) { + } + async function collectCommand() { const removed = [...model.values()].filter(row => row.expiresAt <= clock) const operation = k.transaction(async t => { expect(await Receipt.collectSnapshotJournalReceipts(t)).toBe(removed.length) @@ -1098,9 +1106,11 @@ test('bounded receipt schedules preserve exact committed identity, expiry, floor await operation for (const row of removed) model.delete(row.requestId) } - } else if (command.kind === 3) { + } + async function advanceTime() { clock += command.step - } else { + } + async function advanceFloor() { const next = high > floor ? high : floor await k.transaction(async t => { await t('snapshot_journal_retention').update({ floor: next.toString() }) @@ -1108,6 +1118,11 @@ test('bounded receipt schedules preserve exact committed identity, expiry, floor }) if (!command.rollback) floor = next } + if (command.kind === 0) await recordCommand() + else if (command.kind === 1) await readCommand() + else if (command.kind === 2) await collectCommand() + else if (command.kind === 3) await advanceTime() + else await advanceFloor() const rows = await k('snapshot_journal_receipts').select('*').orderBy('requestId') expect(rows).toEqual([...model.values()].sort((a, b) => a.requestId.localeCompare(b.requestId))) expect(rows.length).toBeLessThanOrEqual(3) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts index e9b80a59b..1b6bc60de 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts @@ -42,30 +42,40 @@ function sqlite(k: Knex): boolean { if (client === 'mysql' || client === 'mysql2') return false return invalid() } +function physicalKeys(table: string, key: string | undefined): string[] { + if (key) return [key] + if (table === 'tx_labels_map') return ['txLabelId', 'transactionId'] + if (table === 'output_tags_map') return ['outputTagId', 'outputId'] + return ['fieldName', 'certificateId'] +} +function physicalRecord( + tableId: number, + key: string | undefined, + row: Record +): Record { + let id1 = row.certificateId, + id2: unknown = 0 + if (key) id1 = row[key] + else if (tableId === 10) { + id1 = row.txLabelId + id2 = row.transactionId + } else if (tableId === 11) { + id1 = row.outputTagId + id2 = row.outputId + } + return { tableId, id1, id2, exactText: tableId === 12 ? row.fieldName : '', present: 1 } +} function streams(local: boolean): Stream[] { const membership = local ? names : legacyNames const tables = [...numeric.map(source => source.table), 'tx_labels_map', 'output_tags_map', 'certificate_fields'] const physical: Stream[] = tables.map((table, tableId) => { const key = numeric[tableId]?.key - const keys = key - ? [key] - : table === 'tx_labels_map' - ? ['txLabelId', 'transactionId'] - : table === 'output_tags_map' - ? ['outputTagId', 'outputId'] - : ['fieldName', 'certificateId'] return { table, - keys, + keys: physicalKeys(table, key), text: tableId === 12 ? 'fieldName' : undefined, physical: true, - record: r => ({ - tableId, - id1: key ? r[key] : tableId === 10 ? r.txLabelId : tableId === 11 ? r.outputTagId : r.certificateId, - id2: tableId === 10 ? r.transactionId : tableId === 11 ? r.outputId : 0, - exactText: tableId === 12 ? r.fieldName : '', - present: 1 - }) + record: row => physicalRecord(tableId, key, row) } }) return [ @@ -143,8 +153,8 @@ function validKey(record: Record): boolean { (record.present === 0 || record.present === 1) ) } -async function invalidate(k: Knex, local: boolean, reason: string): Promise { - if (local) await k('snapshot_journal_clock').where({ id: 1, enabled: 1 }).update({ enabled: 0, reason }) +async function invalidate(k: Knex, reason: string): Promise { + if (sqlite(k)) await k('snapshot_journal_clock').where({ id: 1, enabled: 1 }).update({ enabled: 0, reason }) else await k('snapshot_journal_invalid').insert({ id: 1, reason }).onConflict('id').ignore() } async function revision(k: Knex, local: boolean): Promise { @@ -168,37 +178,45 @@ async function revision(k: Knex, local: boolean): Promise BigInt(MAX_SNAPSHOT_JOURNAL_REVISION)) { - await invalidate(k, local, 'revision-exhausted') + await invalidate(k, 'revision-exhausted') return undefined } const value = snapshotJournalRevision(allocated.value) if (compareSnapshotJournalRevisions(value, ceiling) > 0) { - await invalidate(k, local, 'capacity-exhausted') + await invalidate(k, 'capacity-exhausted') return undefined } return value } -async function queryPage( - k: Knex, - stream: Stream, - cursor: Array | undefined, - local: boolean -): Promise>> { - let query = k.from({ s: stream.table }) - if (!local) { - const [parts]: Array> = await k.raw( - 'SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX', - [stream.table] - ) - const indexes = new Map() - for (const part of parts) { - if (!indexes.has(part.name)) indexes.set(part.name, []) - indexes.get(part.name)!.push(part.field) - } - const index = [...indexes].find(([, fields]) => JSON.stringify(fields) === JSON.stringify(stream.keys))?.[0] - if (!index) return invalid() - query = k.from(k.raw('?? AS ?? FORCE INDEX (??)', [stream.table, 's', index])).forShare() +async function mysqlSourceQuery(k: Knex, stream: Stream): Promise<{ query: Knex.QueryBuilder }> { + const [parts]: Array> = await k.raw( + 'SELECT INDEX_NAME name,COLUMN_NAME field FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX', + [stream.table] + ) + const indexes = new Map() + for (const part of parts) { + if (!indexes.has(part.name)) indexes.set(part.name, []) + indexes.get(part.name)!.push(part.field) } + const index = [...indexes].find(([, fields]) => JSON.stringify(fields) === JSON.stringify(stream.keys))?.[0] + if (!index) return invalid() + return { query: k.from(k.raw('?? AS ?? FORCE INDEX (??)', [stream.table, 's', index])).forShare() } +} +function decodeTextRows(rows: Array>, stream: Stream, local: boolean): void { + if (!stream.text) return + for (const row of rows) { + const bytes = row.boundedText + if (!(bytes instanceof Uint8Array)) return invalid() + const text = Buffer.from(bytes).toString('utf8') + row[stream.text] = + (!local || row.boundedTextType === 'text') && Buffer.from(text, 'utf8').equals(Buffer.from(bytes)) + ? text + : undefined + delete row.boundedTextType + delete row.boundedText + } +} +function projectPage(k: Knex, query: Knex.QueryBuilder, stream: Stream, local: boolean): void { for (const key of stream.keys) { if (key === stream.text) { if (local) query.select(k.raw('typeof(??) AS ??', ['s.' + key, 'boundedTextType'])) @@ -211,23 +229,33 @@ async function queryPage( } else query.select('s.' + key) } if (stream.extra) query.select('s.' + stream.extra) - query.orderBy(stream.keys.map(key => 's.' + key)).limit(256) - if (cursor) { - if (local) - query.whereRaw( - '(' + stream.keys.map(() => '??').join(',') + ') > (' + stream.keys.map(() => '?').join(',') + ')', - [...stream.keys.map(key => 's.' + key), ...cursor] - ) - else - query.where(function () { - stream.keys.forEach((key, index) => { - this.orWhere(function () { - for (let i = 0; i < index; i++) this.where('s.' + stream.keys[i], cursor[i]) - this.where('s.' + key, '>', cursor[index]) - }) +} +function afterCursor(query: Knex.QueryBuilder, stream: Stream, cursor: Array, local: boolean): void { + if (local) + query.whereRaw('(' + stream.keys.map(() => '??').join(',') + ') > (' + stream.keys.map(() => '?').join(',') + ')', [ + ...stream.keys.map(key => 's.' + key), + ...cursor + ]) + else + query.where(function () { + stream.keys.forEach((key, index) => { + this.orWhere(function () { + for (let i = 0; i < index; i++) this.where('s.' + stream.keys[i], cursor[i]) + this.where('s.' + key, '>', cursor[index]) }) }) - } + }) +} +async function queryPage( + k: Knex, + stream: Stream, + cursor: Array | undefined, + local: boolean +): Promise>> { + const query = local ? k.from({ s: stream.table }) : (await mysqlSourceQuery(k, stream)).query + projectPage(k, query, stream, local) + query.orderBy(stream.keys.map(key => 's.' + key)).limit(256) + if (cursor) afterCursor(query, stream, cursor, local) const sql = query.toSQL() const plan = await k.raw((local ? 'EXPLAIN QUERY PLAN ' : 'EXPLAIN ') + sql.sql, sql.bindings as Knex.RawBinding[]) if ( @@ -238,21 +266,81 @@ async function queryPage( return invalid() const rows: Array> = await query if (rows.length > 256) return invalid() - for (const row of rows) - if (stream.text) { - const bytes = row.boundedText - if (!(bytes instanceof Uint8Array)) return invalid() - const text = Buffer.from(bytes).toString('utf8') - row[stream.text] = - (!local || row.boundedTextType === 'text') && Buffer.from(text, 'utf8').equals(Buffer.from(bytes)) - ? text - : undefined - delete row.boundedTextType - delete row.boundedText - } + decodeTextRows(rows, stream, local) return rows } +function bootstrapCursor(stream: Stream, value: string | null): Array | undefined { + if (value === null) return undefined + if (Buffer.byteLength(value) > 2048) return invalid() + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return invalid() + } + if (!Array.isArray(parsed) || parsed.length !== stream.keys.length) return invalid() + for (const [i, value] of parsed.entries()) + if ( + stream.keys[i] === stream.text + ? typeof value !== 'string' || Buffer.byteLength(value) > 400 + : typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 + ) + return invalid() + return parsed as Array +} +interface BootstrapState { + stream: number + cursor: string | null + rowLimit: number | null + rowsUsed: number +} +async function readBootstrapState(t: Knex, streamCount: number, rowLimit: number): Promise { + const state: BootstrapState | undefined = await t('snapshot_journal_bootstrap').where('id', 1).first() + if ( + !state || + !Number.isInteger(state.stream) || + state.stream < 0 || + state.stream > streamCount || + !(state.cursor === null || typeof state.cursor === 'string') || + !validSnapshotJournalBootstrapBudget(state) || + (state.rowLimit !== null && state.rowLimit !== rowLimit) || + (state.rowLimit === null && (state.stream !== 0 || state.cursor !== null)) + ) + return invalid() + return state +} +async function writeBootstrapRecords( + t: Knex, + stream: Stream, + records: Array>, + local: boolean +): Promise { + if (!records.length) return true + const at = await revision(t, local) + if (at === undefined) return false + const table = stream.physical ? 'snapshot_journal_physical' : 'snapshot_journal_scope' + const primary = stream.physical + ? ['tableId', 'id1', 'id2', 'exactText'] + : ['tableId', 'userId', 'id1', 'id2', 'exactText'] + // At most four writes per page, each below the 999-binding SQLite floor. + const batches = Array.from({ length: Math.ceil(records.length / 64) }, (_, i) => records.slice(i * 64, (i + 1) * 64)) + await runInSeries(batches, async batch => { + const query = t(table) + .insert( + batch.map(record => ({ + ...record, + exactText: local ? record.exactText : Buffer.from(record.exactText as string), + revision: snapshotJournalRevisionOperand(t, at), + ...(stream.physical ? { generation: snapshotJournalRevisionOperand(t, at) } : {}) + })) + ) + .onConflict(primary) + if (local) await query.ignore() + else await query.merge({ id1: t.ref(table + '.id1') }) + }) + return true +} export interface SnapshotJournalBootstrapPage { complete: boolean selected: number @@ -280,19 +368,7 @@ export async function copySnapshotJournalBootstrapPage( .where('id', 1) .update({ stream: t.ref('stream') }) else if (!(await t('snapshot_journal_clock').where('id', 1).forUpdate().noWait().first('id'))) return invalid() - const state: { stream: number; cursor: string | null; rowLimit: number | null; rowsUsed: number } | undefined = - await t('snapshot_journal_bootstrap').where('id', 1).first() - if ( - !state || - !Number.isInteger(state.stream) || - state.stream < 0 || - state.stream > all.length || - !(state.cursor === null || typeof state.cursor === 'string') || - !validSnapshotJournalBootstrapBudget(state) || - (state.rowLimit !== null && state.rowLimit !== rowLimit) || - (state.rowLimit === null && (state.stream !== 0 || state.cursor !== null)) - ) - return invalid() + const state = await readBootstrapState(t, all.length, rowLimit) const enabled = local ? (await t('snapshot_journal_clock').where('id', 1).first('enabled'))?.enabled === 1 : !(await t('snapshot_journal_invalid').where('id', 1).first('id')) @@ -305,61 +381,19 @@ export async function copySnapshotJournalBootstrapPage( return { complete: true, selected: 0, stream: state.stream, invalidated: false } } const stream = all[state.stream] - let cursor: Array | undefined - if (state.cursor !== null) { - if (Buffer.byteLength(state.cursor) > 2048) return invalid() - let parsed: unknown - try { - parsed = JSON.parse(state.cursor) - } catch { - return invalid() - } - if (!Array.isArray(parsed) || parsed.length !== stream.keys.length) return invalid() - for (const [i, value] of parsed.entries()) - if ( - stream.keys[i] === stream.text - ? typeof value !== 'string' || Buffer.byteLength(value) > 400 - : typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 - ) - return invalid() - cursor = parsed as Array - } + const cursor = bootstrapCursor(stream, state.cursor) const rows = await queryPage(t, stream, cursor, local), records = rows.map(row => stream.record(row)) if (records.some(record => !validKey(record))) { - await invalidate(t, local, 'key-out-of-range') + await invalidate(t, 'key-out-of-range') return { complete: false, selected: rows.length, stream: state.stream, invalidated: true } } if (records.length > rowLimit - state.rowsUsed) { - await invalidate(t, local, 'capacity-exhausted') + await invalidate(t, 'capacity-exhausted') return { complete: false, selected: rows.length, stream: state.stream, invalidated: true } } - if (records.length) { - const at = await revision(t, local) - if (at === undefined) return { complete: false, selected: rows.length, stream: state.stream, invalidated: true } - const table = stream.physical ? 'snapshot_journal_physical' : 'snapshot_journal_scope' - const primary = stream.physical - ? ['tableId', 'id1', 'id2', 'exactText'] - : ['tableId', 'userId', 'id1', 'id2', 'exactText'] - // At most four writes per page, each below the 999-binding SQLite floor. - const batches = Array.from({ length: Math.ceil(records.length / 64) }, (_, i) => - records.slice(i * 64, (i + 1) * 64) - ) - await runInSeries(batches, async batch => { - const query = t(table) - .insert( - batch.map(record => ({ - ...record, - exactText: local ? record.exactText : Buffer.from(record.exactText as string), - revision: snapshotJournalRevisionOperand(t, at), - ...(stream.physical ? { generation: snapshotJournalRevisionOperand(t, at) } : {}) - })) - ) - .onConflict(primary) - if (local) await query.ignore() - else await query.merge({ id1: t.ref(table + '.id1') }) - }) - } + if (!(await writeBootstrapRecords(t, stream, records, local))) + return { complete: false, selected: rows.length, stream: state.stream, invalidated: true } const last = rows.at(-1) await t('snapshot_journal_bootstrap') .where('id', 1) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts index b442c745d..a2b0c5c23 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.test.ts @@ -98,12 +98,12 @@ async function fixture() { ) return database.raw(sql) if (sql === 'SELECT VERSION() version') return Promise.resolve([[{ version: '8.4.0' }]]) - const name = /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] + const name = /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_\w+)/.exec(sql)?.[1] if (name) return (async () => { fail('before:' + name) const reference = nativeDdl.ddl.find( - entry => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(entry.sql)?.[1] === name + entry => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_\w+)/.exec(entry.sql)?.[1] === name ) if (!reference || !available || available.has(name)) throw new Error('Unowned fixture DDL') if (name === 'snapshot_journal_generation') activeEpoch = String(values?.[0]) @@ -506,9 +506,7 @@ test.each([ } }) -const objectNames = nativeDdl.ddl.map( - entry => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(entry.sql)![1] -) +const objectNames = nativeDdl.ddl.map(entry => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_\w+)/.exec(entry.sql)![1]) test('fresh installation uses the independently captured native DDL and atomically seeded controls', async () => { const f = await fixture() try { @@ -543,9 +541,7 @@ test.each(objectNames)('every DDL acknowledgement loss resumes without adopting/ enabled: true }) expect( - f.raw.mock.calls.filter( - ([sql]) => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] === name - ) + f.raw.mock.calls.filter(([sql]) => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_\w+)/.exec(sql)?.[1] === name) ).toHaveLength(1) } finally { await f.database.destroy() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts index 207de76f2..26ac8e017 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts @@ -222,7 +222,7 @@ async function plan( return invalid() const triggers = (await snapshotJournalMysqlObserverSql(k)).map(sql => { const match = - /^CREATE TRIGGER (snapshot_journal_[A-Za-z0-9_]+) AFTER (INSERT|UPDATE|DELETE) ON `([a-z_]+)` FOR EACH ROW (BEGIN .*)$/s.exec( + /^CREATE TRIGGER (snapshot_journal_\w+) AFTER (INSERT|UPDATE|DELETE) ON `([a-z_]+)` FOR EACH ROW (BEGIN .*)$/s.exec( sql ) if (!match) return invalid() @@ -343,7 +343,7 @@ async function validateTable(k: Knex, table: Table, epoch: string): Promise { + const rows = await k('snapshot_journal_retention') + .select( + 'id', + 'receiptLimit', + k.raw('CAST(receiptLifetimeMs AS CHAR) receiptLifetimeMs'), + k.raw('SUBSTRING(floor,1,20) floor') + ) + .limit(2) + if ( + rows.length !== 1 || + rows[0].id !== 1 || + rows[0].receiptLimit !== policy.receiptLimit || + rows[0].receiptLifetimeMs !== String(policy.receiptLifetimeMs) + ) + return invalid() + const floor = snapshotJournalRevision(rows[0].floor) + if (compareSnapshotJournalRevisions(floor, state.ceiling) > 0 || (state.nextObject < objectCount && floor !== '0')) + return invalid() +} + async function validateObject( k: Knex, object: ObjectDefinition, @@ -387,29 +412,7 @@ async function validateObject( ) return invalid() } - if (object.definition.seed === 'retention') { - const rows = await k('snapshot_journal_retention') - .select( - 'id', - 'receiptLimit', - k.raw('CAST(receiptLifetimeMs AS CHAR) receiptLifetimeMs'), - k.raw('SUBSTRING(floor,1,20) floor') - ) - .limit(2) - if ( - rows.length !== 1 || - rows[0].id !== 1 || - rows[0].receiptLimit !== policy.receiptLimit || - rows[0].receiptLifetimeMs !== String(policy.receiptLifetimeMs) - ) - return invalid() - const floor = snapshotJournalRevision(rows[0].floor) - if ( - compareSnapshotJournalRevisions(floor, state.ceiling) > 0 || - (state.nextObject < objectCount && floor !== '0') - ) - return invalid() - } + if (object.definition.seed === 'retention') await validateRetention(k, state, policy) return } const definition = object.definition, @@ -588,8 +591,7 @@ export async function completeSnapshotJournalMysqlGeneration( if (state.epoch !== validated.epoch || state.nextObject !== objectCount) return invalid() const progress = await t('snapshot_journal_bootstrap').where('id', 1).first('stream', 'cursor') if ( - !progress || - progress.stream !== 17 || + progress?.stream !== 17 || progress.cursor !== null || (await t('snapshot_journal_invalid').where('id', 1).first('id')) ) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts index 15ca35df5..48c94f130 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlObservers.ts @@ -17,13 +17,10 @@ export const SNAPSHOT_JOURNAL_MYSQL_METADATA_DDL = [ ] const tuple = (table: string, p: string): string[] => { const key = numeric.find(source => source.table === table)?.key - return key - ? [p + '.' + q(key), '0', "CAST('' AS BINARY)"] - : table === 'tx_labels_map' - ? [p + '.txLabelId', p + '.transactionId', "CAST('' AS BINARY)"] - : table === 'output_tags_map' - ? [p + '.outputTagId', p + '.outputId', "CAST('' AS BINARY)"] - : [p + '.certificateId', '0', 'CAST(' + p + '.fieldName AS BINARY)'] + if (key) return [p + '.' + q(key), '0', "CAST('' AS BINARY)"] + if (table === 'tx_labels_map') return [p + '.txLabelId', p + '.transactionId', "CAST('' AS BINARY)"] + if (table === 'output_tags_map') return [p + '.outputTagId', p + '.outputId', "CAST('' AS BINARY)"] + return [p + '.certificateId', '0', 'CAST(' + p + '.fieldName AS BINARY)'] } const scopeUpsert = (selection: string) => `INSERT INTO snapshot_journal_scope(${scopeKey},revision,present) ${selection} ON DUPLICATE KEY UPDATE revision=VALUES(revision),present=VALUES(present); ` @@ -38,6 +35,44 @@ function keyGuard(key: string[], owner?: string): string { return `IF NOT COALESCE((${valid}),FALSE) THEN INSERT IGNORE INTO snapshot_journal_invalid(id,reason) VALUES(1,'key-out-of-range'); SET journalEnabled=FALSE; END IF; ` } +function sourceScope(tableId: number, keys: string[]): string { + if (tableId < 8) + return scopeUpsert( + `SELECT ${tableId},NEW.userId,NEW.${q(numeric[tableId].key)},0,CAST('' AS BINARY),journalRevision,1` + ) + else if (tableId === 10 || tableId === 11) + return scopeUpsert( + `SELECT ${tableId},snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=${tableId - 10} AND snapshotLeftId=${keys[0]} AND snapshotRightId=${keys[1]} AND snapshotMembership<>0 FOR SHARE` + ) + else if (tableId === 12) + return scopeUpsert( + `SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE` + ) + return '' +} +function freshGeneration(event: string, same: string): string { + if (event === 'INSERT') return 'TRUE' + if (event === 'UPDATE') return `NOT (${same})` + return 'FALSE' +} +function physicalTrigger(tableId: number, table: string, event: string, changed: string): string { + const same = tuple(table, 'OLD') + .map((x, i) => `(${x} <=> ${tuple(table, 'NEW')[i]})`) + .join(' AND ') + const write = (p: string, present: number, fresh: string) => + `INSERT INTO snapshot_journal_physical(${physicalKey},revision,generation,present) VALUES(${tableId},${tuple(table, p).join(',')},journalRevision,journalRevision,${present}) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN ${fresh} THEN VALUES(generation) ELSE generation END,present=VALUES(present); ` + const p = event === 'DELETE' ? 'OLD' : 'NEW', + keys = tuple(table, p) + let body = advance + keyGuard(keys, tableId < 8 ? p + '.userId' : undefined) + if (event === 'UPDATE') body += keyGuard(tuple(table, 'OLD'), tableId < 8 ? 'OLD.userId' : undefined) + body += 'IF journalEnabled THEN ' + if (event === 'UPDATE') body += `IF NOT (${same}) THEN ${write('OLD', 0, 'FALSE')} END IF; ` + body += write(p, event === 'DELETE' ? 0 : 1, freshGeneration(event, same)) + if (event !== 'DELETE') body += sourceScope(tableId, keys) + body += 'END IF; ' + return `CREATE TRIGGER snapshot_journal_physical_${tableId}_${event} AFTER ${event} ON ${q(table)} FOR EACH ROW BEGIN ${variables}IF ${event === 'UPDATE' ? changed : 'TRUE'} THEN ${body} END IF; END` +} + /** Caller validates the completed MySQL membership/source schema before installing these observers. */ export async function snapshotJournalMysqlObserverSql(k: Knex): Promise { if (!['mysql', 'mysql2'].includes(k.client.config.client)) @@ -117,42 +152,8 @@ export async function snapshotJournalMysqlObserverSql(k: Knex): Promise `NOT (CAST(OLD.${q(name)} AS BINARY) <=> CAST(NEW.${q(name)} AS BINARY))`) .join(' OR ') - const same = tuple(table, 'OLD') - .map((x, i) => `(${x} <=> ${tuple(table, 'NEW')[i]})`) - .join(' AND ') - const write = (p: string, present: number, fresh: string) => - `INSERT INTO snapshot_journal_physical(${physicalKey},revision,generation,present) VALUES(${tableId},${tuple(table, p).join(',')},journalRevision,journalRevision,${present}) ON DUPLICATE KEY UPDATE revision=VALUES(revision),generation=CASE WHEN ${fresh} THEN VALUES(generation) ELSE generation END,present=VALUES(present); ` - for (const event of ['INSERT', 'UPDATE', 'DELETE']) { - const p = event === 'DELETE' ? 'OLD' : 'NEW', - keys = tuple(table, p) - let body = advance + keyGuard(keys, tableId < 8 ? p + '.userId' : undefined) - if (event === 'UPDATE') body += keyGuard(tuple(table, 'OLD'), tableId < 8 ? 'OLD.userId' : undefined) - body += 'IF journalEnabled THEN ' - if (event === 'UPDATE') body += `IF NOT (${same}) THEN ${write('OLD', 0, 'FALSE')} END IF; ` - body += write( - p, - event === 'DELETE' ? 0 : 1, - event === 'INSERT' ? 'TRUE' : event === 'UPDATE' ? `NOT (${same})` : 'FALSE' - ) - if (event !== 'DELETE') { - if (tableId < 8) - body += scopeUpsert( - `SELECT ${tableId},NEW.userId,NEW.${q(numeric[tableId].key)},0,CAST('' AS BINARY),journalRevision,1` - ) - else if (tableId === 10 || tableId === 11) - body += scopeUpsert( - `SELECT ${tableId},snapshotUserId,snapshotLeftId,snapshotRightId,CAST('' AS BINARY),journalRevision,1 FROM snapshot_relation_keys WHERE snapshotTableId=${tableId - 10} AND snapshotLeftId=${keys[0]} AND snapshotRightId=${keys[1]} AND snapshotMembership<>0 FOR SHARE` - ) - else if (tableId === 12) - body += scopeUpsert( - `SELECT 12,snapshotUserId,snapshotCertificateId,0,CAST(snapshotFieldName AS BINARY),journalRevision,1 FROM snapshot_certificate_field_keys WHERE snapshotCertificateId=NEW.certificateId AND snapshotFieldName=NEW.fieldName AND snapshotMembership<>0 FOR SHARE` - ) - } - body += 'END IF; ' - definitions.push( - `CREATE TRIGGER snapshot_journal_physical_${tableId}_${event} AFTER ${event} ON ${q(table)} FOR EACH ROW BEGIN ${variables}IF ${event === 'UPDATE' ? changed : 'TRUE'} THEN ${body} END IF; END` - ) - } + for (const event of ['INSERT', 'UPDATE', 'DELETE']) + definitions.push(physicalTrigger(tableId, table, event, changed)) }) return definitions } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts index e29eae4eb..231ffcd43 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMysqlSource.ts @@ -21,6 +21,27 @@ import { function invalid(): never { throw new WERR_INVALID_OPERATION('Unsupported or incomplete MySQL snapshot journal source') } +interface SourceColumn { + tableName: string + name: string + type: string + nullable: string + extra: string +} +function validateNumericColumns(columns: SourceColumn[]): void { + for (const source of numeric) { + for (const name of [source.key, ...(source.owner ? [source.owner] : [])]) { + const found = columns.filter(column => column.tableName === source.table && column.name === name) + if ( + found.length !== 1 || + found[0].type.replaceAll(/\(\d+\)/g, '') !== 'int unsigned' || + found[0].nullable !== 'NO' || + !(found[0].extra === '' || (name === source.key && found[0].extra === 'auto_increment')) + ) + return invalid() + } + } +} /** Read-only prerequisite guard; ownership/install intents and epoch/receipt provenance remain the migrator's responsibility. */ export async function validateSnapshotJournalMysqlSource(k: Knex, config?: Knex.MigratorConfig): Promise { if (!['mysql', 'mysql2'].includes(k.client.config.client)) return invalid() @@ -52,13 +73,12 @@ export async function validateSnapshotJournalMysqlSource(k: Knex, config?: Knex. tables.some(table => table.engine !== 'InnoDB' || table.type !== 'BASE TABLE') ) return invalid() - const [columns]: Array> = - await k.raw( - 'SELECT TABLE_NAME tableName,COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,EXTRA extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (' + - sourceTables.map(() => '?').join(',') + - ') ORDER BY TABLE_NAME,ORDINAL_POSITION LIMIT 513', - sourceTables - ) + const [columns]: Array = await k.raw( + 'SELECT TABLE_NAME tableName,COLUMN_NAME name,COLUMN_TYPE type,IS_NULLABLE nullable,EXTRA extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (' + + sourceTables.map(() => '?').join(',') + + ') ORDER BY TABLE_NAME,ORDINAL_POSITION LIMIT 513', + sourceTables + ) const [parts]: Array< Array<{ tableName: string @@ -76,18 +96,7 @@ export async function validateSnapshotJournalMysqlSource(k: Knex, config?: Knex. sourceTables ) if (columns.length > 512 || parts.length > 512) return invalid() - for (const source of numeric) { - for (const name of [source.key, ...(source.owner ? [source.owner] : [])]) { - const found = columns.filter(column => column.tableName === source.table && column.name === name) - if ( - found.length !== 1 || - found[0].type.replaceAll(/\(\d+\)/g, '') !== 'int unsigned' || - found[0].nullable !== 'NO' || - !(found[0].extra === '' || (name === source.key && found[0].extra === 'auto_increment')) - ) - return invalid() - } - } + validateNumericColumns(columns) const identities = [ ...numeric.map(source => ({ table: source.table, keys: [source.key], primary: true })), { table: 'tx_labels_map', keys: ['txLabelId', 'transactionId'], primary: false }, diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts index 5c17ff19b..95f3e2f44 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts @@ -169,7 +169,7 @@ async function now(k: Knex): Promise { return value } function storedInteger(value: unknown): number { - if (typeof value === 'string' && /^(?:0|[1-9][0-9]{0,15})$/.test(value)) value = Number(value) + if (typeof value === 'string' && /^(?:0|[1-9]\d{0,15})$/.test(value)) value = Number(value) if (!boundedInteger(value, Number.MAX_SAFE_INTEGER)) return invalid() return value } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts index 856685d07..d41161751 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts @@ -32,6 +32,12 @@ interface Query { response?: unknown } +function stringifyBigints(rows: Array>): void { + for (const row of rows) + for (const field of ['expiresAt', 'receiptLifetimeMs']) + if (row[field] !== undefined) row[field] = String(row[field]) +} + // Real MySQL query compilation/response processing, backed by SQLite for DML // and rollback. The independent native fixture proves MySQL isolation/locks. async function fixture(strings: boolean) { @@ -69,10 +75,7 @@ async function fixture(strings: boolean) { const sql = q.sql.replace(/ (?:for (?:share|update)(?: nowait)?|lock in share mode)$/i, '') const result = await db.raw(sql, q.bindings) if (Array.isArray(result)) { - if (strings) - for (const row of result) - for (const field of ['expiresAt', 'receiptLifetimeMs']) - if (row[field] !== undefined) row[field] = String(row[field]) + if (strings) stringifyBigints(result) return respond(result) } return respond({ affectedRows: result?.changes ?? 0, insertId: result?.lastInsertRowid ?? 0 }) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevision.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevision.ts index 6bf9c8417..3a3f93512 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevision.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalRevision.ts @@ -10,7 +10,7 @@ export type SnapshotJournalRevision = string & { readonly [journalRevision]: tru export function snapshotJournalRevision(value: unknown): SnapshotJournalRevision { if ( typeof value !== 'string' || - !/^(?:0|[1-9][0-9]{0,18})$/.test(value) || + !/^(?:0|[1-9]\d{0,18})$/.test(value) || (value.length === 19 && value > MAX_SNAPSHOT_JOURNAL_REVISION) ) { throw new WERR_INVALID_OPERATION('Invalid snapshot journal revision') diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts index 4b473a0a2..a8a307521 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.test.ts @@ -347,12 +347,12 @@ test.each(['install', 'read', 'complete'])( const transaction = jest.fn(), raw = jest.fn() const k = { client: { config: { client: 'mysql2' } }, transaction, raw } as unknown as Knex - const result = - operation === 'install' - ? installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy) - : operation === 'read' - ? readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy) - : completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy) + const operations = { + install: () => installSnapshotJournalSqliteGeneration(k, ceiling, journalReceiptPolicy), + read: () => readSnapshotJournalSqliteGeneration(k, journalReceiptPolicy), + complete: () => completeSnapshotJournalSqliteGeneration(k, journalReceiptPolicy) + } + const result = operations[operation as keyof typeof operations]() await expect(result).rejects.toThrow('Invalid or unowned SQLite snapshot journal generation') expect(transaction).not.toHaveBeenCalled() expect(raw).not.toHaveBeenCalled() diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts index fd15bce41..32e1b6bd9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts @@ -62,7 +62,7 @@ async function plan(k: Knex, config?: Knex.MigratorConfig): Promise { ...(await snapshotJournalSqliteObserverSql(k)) ] const objects = ddl.map(sql => { - const match = /^CREATE (TABLE|INDEX|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql) + const match = /^CREATE (TABLE|INDEX|TRIGGER) (snapshot_journal_\w+)/.exec(sql) if (!match) return invalid() return { type: match[1].toLowerCase(), name: match[2], sql } }) @@ -99,6 +99,47 @@ async function reserved(k: Knex): Promise { .orderBy(['type', 'name']) .limit(513) } +async function validateBootstrap(k: Knex, complete: number): Promise { + const positions = await k('snapshot_journal_bootstrap').select('*').limit(2) + if (positions.length !== 1) return invalid() + const position = positions[0] + if ( + position.id !== 1 || + !Number.isInteger(position.stream) || + position.stream < 0 || + position.stream > 17 || + !( + position.cursor === null || + (typeof position.cursor === 'string' && Buffer.byteLength(position.cursor, 'utf8') <= 2048) + ) || + (position.stream === 17 && position.cursor !== null) || + (complete === 1 && position.stream !== 17) || + !validSnapshotJournalBootstrapBudget(position) || + (position.rowLimit === null && (position.stream !== 0 || position.cursor !== null)) + ) + return invalid() +} +async function validateRetention( + k: Knex, + policy: SnapshotJournalReceiptPolicy, + ceiling: SnapshotJournalRevision +): Promise { + const retention = await k('snapshot_journal_retention') + .select('id', 'receiptLimit', 'receiptLifetimeMs', k.raw('substr(??,1,20) AS ??', ['floor', 'floor'])) + .limit(2) + if ( + retention.length !== 1 || + retention[0].id !== 1 || + retention[0].receiptLimit !== policy.receiptLimit || + retention[0].receiptLifetimeMs !== policy.receiptLifetimeMs || + compareSnapshotJournalRevisions(snapshotJournalRevision(retention[0].floor), ceiling) > 0 + ) + return invalid() + const receipts = await k('snapshot_journal_receipts') + .select(k.raw('1 AS occupied')) + .limit(policy.receiptLimit + 1) + if (receipts.length > policy.receiptLimit) return invalid() +} async function validate( k: Knex, p: Plan, @@ -141,39 +182,8 @@ async function validate( : clock.enabled !== 0 || !['capacity-exhausted', 'revision-exhausted', 'key-out-of-range'].includes(clock.reason) ) return invalid() - const positions = await k('snapshot_journal_bootstrap').select('*').limit(2) - if (positions.length !== 1) return invalid() - const position = positions[0] - if ( - position.id !== 1 || - !Number.isInteger(position.stream) || - position.stream < 0 || - position.stream > 17 || - !( - position.cursor === null || - (typeof position.cursor === 'string' && Buffer.byteLength(position.cursor, 'utf8') <= 2048) - ) || - (position.stream === 17 && position.cursor !== null) || - (row.complete === 1 && position.stream !== 17) || - !validSnapshotJournalBootstrapBudget(position) || - (position.rowLimit === null && (position.stream !== 0 || position.cursor !== null)) - ) - return invalid() - const retention = await k('snapshot_journal_retention') - .select('id', 'receiptLimit', 'receiptLifetimeMs', k.raw('substr(??,1,20) AS ??', ['floor', 'floor'])) - .limit(2) - if ( - retention.length !== 1 || - retention[0].id !== 1 || - retention[0].receiptLimit !== policy.receiptLimit || - retention[0].receiptLifetimeMs !== policy.receiptLifetimeMs || - compareSnapshotJournalRevisions(snapshotJournalRevision(retention[0].floor), ceiling) > 0 - ) - return invalid() - const receipts = await k('snapshot_journal_receipts') - .select(k.raw('1 AS occupied')) - .limit(policy.receiptLimit + 1) - if (receipts.length > policy.receiptLimit) return invalid() + await validateBootstrap(k, row.complete) + await validateRetention(k, policy, ceiling) return { epoch: row.epoch, source: p.source, diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts index d960951b3..805fe4177 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts @@ -59,6 +59,42 @@ function keyGuard(key: string[], owner?: string): string { return `UPDATE snapshot_journal_clock SET enabled=0,reason='key-out-of-range' WHERE id=1 AND enabled=1 AND NOT (${valid}); ` } +function sourceScope(tableId: number, table: string, prefix: string, current: string[]): string { + if (tableId < 8) { + return scopeUpsert( + `SELECT ${tableId},s.userId,s.${q(numeric[tableId].key)},0,'',${revision},1 FROM ${q(table)} s WHERE ${sourceWhere(table, prefix, 's')} AND ${writable}` + ) + } else if (tableId === 10 || tableId === 11) { + return scopeUpsert( + `SELECT ${tableId},snapshotUserId,snapshotLeftId,snapshotRightId,'',${revision},1 FROM ${names.relation} WHERE snapshotTableId=${tableId - 10} AND snapshotLeftId=${current[0]} AND snapshotRightId=${current[1]} AND ${writable}` + ) + } else if (tableId === 12) { + return scopeUpsert( + `SELECT 12,snapshotUserId,snapshotCertificateId,0,snapshotFieldName,${revision},1 FROM ${names.certificate} WHERE snapshotCertificateId=${current[0]} AND snapshotFieldName=${current[2]} AND ${writable}` + ) + } + return '' +} +function freshGeneration(event: string, table: string): string { + if (event === 'INSERT') return '1' + if (event === 'UPDATE') return `NOT (${exactWhere(table, 'OLD', 'NEW')})` + return '0' +} +function physicalTrigger(tableId: number, table: string, event: string, changed: string): string { + const prefix = event === 'DELETE' ? 'OLD' : 'NEW', + current = tuple(table, prefix) + const regenerate = freshGeneration(event, table) + const writePhysical = (p: string, fresh: string) => + `INSERT INTO snapshot_journal_physical(${physicalKey},revision,generation,present) SELECT ${tableId},${tuple(table, p).join(',')},${revision},${revision},EXISTS(SELECT 1 FROM ${q(table)} s WHERE ${sourceWhere(table, p, 's')}) WHERE ${writable} ON CONFLICT(${physicalKey}) DO UPDATE SET revision=excluded.revision,generation=CASE WHEN ${fresh} THEN excluded.generation ELSE snapshot_journal_physical.generation END,present=excluded.present; ` + let body = keyGuard(current, tableId < 8 ? prefix + '.userId' : undefined) + if (event === 'UPDATE') body += keyGuard(tuple(table, 'OLD'), tableId < 8 ? 'OLD.userId' : undefined) + body += tick + if (event === 'UPDATE') body += writePhysical('OLD', '0') + body += writePhysical(prefix, regenerate) + body += sourceScope(tableId, table, prefix, current) + return `CREATE TRIGGER snapshot_journal_physical_${tableId}_${event} AFTER ${event} ON ${q(table)} ${event === 'UPDATE' ? 'WHEN ' + changed : ''} BEGIN ${body} END` +} + /** Prepare observers only after the caller validates the completed v2 source generation. */ export async function snapshotJournalSqliteObserverSql(k: Knex): Promise { if (!['sqlite3', 'better-sqlite3'].includes(k.client.config.client)) @@ -111,35 +147,8 @@ export async function snapshotJournalSqliteObserverSql(k: Knex): Promise `CAST(OLD.${q(name)} AS BLOB) IS NOT CAST(NEW.${q(name)} AS BLOB)`) .join(' OR ') - for (const event of ['INSERT', 'UPDATE', 'DELETE']) { - const prefix = event === 'DELETE' ? 'OLD' : 'NEW', - current = tuple(table, prefix) - const regenerate = - event === 'INSERT' ? '1' : event === 'UPDATE' ? `NOT (${exactWhere(table, 'OLD', 'NEW')})` : '0' - const writePhysical = (p: string, fresh: string) => - `INSERT INTO snapshot_journal_physical(${physicalKey},revision,generation,present) SELECT ${tableId},${tuple(table, p).join(',')},${revision},${revision},EXISTS(SELECT 1 FROM ${q(table)} s WHERE ${sourceWhere(table, p, 's')}) WHERE ${writable} ON CONFLICT(${physicalKey}) DO UPDATE SET revision=excluded.revision,generation=CASE WHEN ${fresh} THEN excluded.generation ELSE snapshot_journal_physical.generation END,present=excluded.present; ` - let body = keyGuard(current, tableId < 8 ? prefix + '.userId' : undefined) - if (event === 'UPDATE') body += keyGuard(tuple(table, 'OLD'), tableId < 8 ? 'OLD.userId' : undefined) - body += tick - if (event === 'UPDATE') body += writePhysical('OLD', '0') - body += writePhysical(prefix, regenerate) - if (tableId < 8) { - body += scopeUpsert( - `SELECT ${tableId},s.userId,s.${q(numeric[tableId].key)},0,'',${revision},1 FROM ${q(table)} s WHERE ${sourceWhere(table, prefix, 's')} AND ${writable}` - ) - } else if (tableId === 10 || tableId === 11) { - body += scopeUpsert( - `SELECT ${tableId},snapshotUserId,snapshotLeftId,snapshotRightId,'',${revision},1 FROM ${names.relation} WHERE snapshotTableId=${tableId - 10} AND snapshotLeftId=${current[0]} AND snapshotRightId=${current[1]} AND ${writable}` - ) - } else if (tableId === 12) { - body += scopeUpsert( - `SELECT 12,snapshotUserId,snapshotCertificateId,0,snapshotFieldName,${revision},1 FROM ${names.certificate} WHERE snapshotCertificateId=${current[0]} AND snapshotFieldName=${current[2]} AND ${writable}` - ) - } - definitions.push( - `CREATE TRIGGER snapshot_journal_physical_${tableId}_${event} AFTER ${event} ON ${q(table)} ${event === 'UPDATE' ? 'WHEN ' + changed : ''} BEGIN ${body} END` - ) - } + for (const event of ['INSERT', 'UPDATE', 'DELETE']) + definitions.push(physicalTrigger(tableId, table, event, changed)) }) return definitions } diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs index d1ad79b88..5f1102461 100644 --- a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs @@ -20,6 +20,17 @@ const execute = (file, args, options) => ) }) +function fixtureScript(group) { + if (group === 'generation') return 'snapshotJournalMysql.cjs' + if (group === 'server-crash') return 'snapshotJournalMysqlServerCrash.cjs' + return 'snapshotJournalReceiptMysql.cjs' +} +function fixtureTimeout(group) { + if (group === 'generation') return 180000 + if (group === 'server-crash') return 240000 + return 60000 +} + async function runFixture(group) { assert(journalFixtureGroups.includes(group)) const secret = randomBytes(32).toString('hex') @@ -106,31 +117,17 @@ async function runFixture(group) { { const started = Date.now() process.stdout.write(JSON.stringify({ group, status: 'started' }) + '\n') - const result = await execute( - process.execPath, - [ - join( - __dirname, - group === 'generation' - ? 'snapshotJournalMysql.cjs' - : group === 'server-crash' - ? 'snapshotJournalMysqlServerCrash.cjs' - : 'snapshotJournalReceiptMysql.cjs' - ), - group - ], - { - env: { - ...process.env, - TS_STACK_SNAPSHOT_CONTAINER: name, - TS_STACK_SNAPSHOT_CONTAINER_ID: id, - TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, - TS_STACK_SNAPSHOT_MYSQL_SECRET: secret - }, - signal: cancellation.signal, - timeout: group === 'generation' ? 180000 : group === 'server-crash' ? 240000 : 60000 - } - ) + const result = await execute(process.execPath, [join(__dirname, fixtureScript(group)), group], { + env: { + ...process.env, + TS_STACK_SNAPSHOT_CONTAINER: name, + TS_STACK_SNAPSHOT_CONTAINER_ID: id, + TS_STACK_SNAPSHOT_CONTAINER_OWNER: owner, + TS_STACK_SNAPSHOT_MYSQL_SECRET: secret + }, + signal: cancellation.signal, + timeout: fixtureTimeout(group) + }) process.stdout.write(result) process.stdout.write(JSON.stringify({ group, status: 'passed', milliseconds: Date.now() - started }) + '\n') } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs index 9878cf5e4..5609924ef 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs @@ -1,3 +1,4 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') const receiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } const assert = require('node:assert/strict'), { fork } = require('node:child_process'), @@ -30,27 +31,42 @@ async function isolate(k, isolation) { assert.equal(level.isolation.replaceAll('-', ' '), isolation) await k.raw('SET SESSION innodb_lock_wait_timeout=5') } -async function finish(k) { - for (let i = 0; i < 100; i++) { +async function bootstrap(k) { + let complete = false + function* pages() { + for (let i = 0; i < 100 && !complete; i++) yield i + } + await runInSeries(pages(), async () => { const page = await copy(k, 1000000) assert(!page.invalidated) - if (page.complete) return await complete(k, ceiling, receiptPolicy) - assert(i < 99) - } + complete = page.complete + }) + assert(complete, 'Bootstrap did not complete within its bounded fixture pages') +} +async function finish(k) { + await bootstrap(k) + return await complete(k, ceiling, receiptPolicy) } + async function clear(k) { const [triggers] = await k.raw( "SELECT TRIGGER_NAME name FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND LEFT(TRIGGER_NAME,17)='snapshot_journal_'" ) - for (const row of triggers) await k.raw('DROP TRIGGER ??', [row.name]) + await runInSeries(triggers, async row => { + await k.raw('DROP TRIGGER ??', [row.name]) + }) const [names] = await k.raw( "SELECT TABLE_NAME name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,17)='snapshot_journal_'" ) - for (const row of names) await k.schema.dropTable(row.name) + await runInSeries(names, async row => { + await k.schema.dropTable(row.name) + }) } async function rows(k) { const result = {} - for (const name of tables) result[name] = (await k(name).select('*')).map(row => JSON.stringify(row)).sort() + await runInSeries(tables, async name => { + result[name] = (await k(name).select('*')).map(row => JSON.stringify(row)).sort() + }) return result } async function child() { @@ -68,7 +84,7 @@ async function child() { process.kill(process.pid, 'SIGKILL') } } - const object = sql => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] + const object = sql => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_\w+)/.exec(sql)?.[1] k.on('query', q => { const name = object(q.sql) if (name) park('before-' + name) @@ -91,11 +107,7 @@ async function child() { park('bootstrap-after-commit') } if (boundary.startsWith('complete-')) { - for (let i = 0; i < 100; i++) { - const page = await copy(k, 1000000) - if (page.complete) break - assert(i < 99) - } + await bootstrap(k) completing = true await complete(k, ceiling, receiptPolicy) park('complete-after-commit') @@ -134,7 +146,7 @@ async function main() { { user: foreign } = await source.findOrInsertUser('03' + '22'.repeat(32)) await seedArchiveClosure(source, user.userId, foreign.userId) const baseline = await rows(k) - for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) { + await runInSeries(['READ COMMITTED', 'REPEATABLE READ'], async isolation => { await isolate(k, isolation) await clear(k) const created = await install(k, ceiling, receiptPolicy) @@ -164,39 +176,42 @@ async function main() { assert.deepEqual(await read(k, ceiling, receiptPolicy), completed) assert.deepEqual(await complete(k, ceiling, receiptPolicy), completed) await k.transaction(async t => assert.deepEqual(await read(t, ceiling, receiptPolicy), completed)) - for (const [_name, up, down] of [ - [ - 'owner-comment', - "ALTER TABLE snapshot_journal_clock COMMENT='foreign'", - "ALTER TABLE snapshot_journal_clock COMMENT='snapshot-journal-owner:" + created.epoch + "'" - ], + await runInSeries( [ - 'index', - 'CREATE INDEX foreign_generation_index ON snapshot_journal_physical(present)', - 'DROP INDEX foreign_generation_index ON snapshot_journal_physical' + [ + 'owner-comment', + "ALTER TABLE snapshot_journal_clock COMMENT='foreign'", + "ALTER TABLE snapshot_journal_clock COMMENT='snapshot-journal-owner:" + created.epoch + "'" + ], + [ + 'index', + 'CREATE INDEX foreign_generation_index ON snapshot_journal_physical(present)', + 'DROP INDEX foreign_generation_index ON snapshot_journal_physical' + ], + [ + 'check', + 'ALTER TABLE snapshot_journal_clock ALTER CHECK snapshot_journal_clock_chk_1 NOT ENFORCED', + 'ALTER TABLE snapshot_journal_clock ALTER CHECK snapshot_journal_clock_chk_1 ENFORCED' + ], + [ + 'foreign-observer', + 'CREATE TRIGGER foreign_generation_observer AFTER UPDATE ON snapshot_journal_clock FOR EACH ROW BEGIN DO 0; END', + 'DROP TRIGGER foreign_generation_observer' + ], + [ + 'source-binding', + "ALTER TABLE tx_labels ALTER label SET DEFAULT 'changed'", + 'ALTER TABLE tx_labels ALTER label DROP DEFAULT' + ] ], - [ - 'check', - 'ALTER TABLE snapshot_journal_clock ALTER CHECK snapshot_journal_clock_chk_1 NOT ENFORCED', - 'ALTER TABLE snapshot_journal_clock ALTER CHECK snapshot_journal_clock_chk_1 ENFORCED' - ], - [ - 'foreign-observer', - 'CREATE TRIGGER foreign_generation_observer AFTER UPDATE ON snapshot_journal_clock FOR EACH ROW BEGIN DO 0; END', - 'DROP TRIGGER foreign_generation_observer' - ], - [ - 'source-binding', - "ALTER TABLE tx_labels ALTER label SET DEFAULT 'changed'", - 'ALTER TABLE tx_labels ALTER label DROP DEFAULT' - ] - ]) { - await k.raw(up) - await assert.rejects(read(k, ceiling, receiptPolicy), /Invalid or unowned/) - await assert.rejects(install(k, ceiling, receiptPolicy), /Invalid or unowned/) - await k.raw(down) - assert.deepEqual(await read(k, ceiling, receiptPolicy), completed) - } + async ([, up, down]) => { + await k.raw(up) + await assert.rejects(read(k, ceiling, receiptPolicy), /Invalid or unowned/) + await assert.rejects(install(k, ceiling, receiptPolicy), /Invalid or unowned/) + await k.raw(down) + assert.deepEqual(await read(k, ceiling, receiptPolicy), completed) + } + ) await k(intent).update({ nextObject: 58, complete: 0 }) await k.raw('CREATE TABLE snapshot_journal_foreign(id INT)') await assert.rejects(install(k, ceiling, receiptPolicy), /Invalid or unowned/) @@ -233,7 +248,7 @@ async function main() { 'complete-before-commit', 'complete-after-commit' ] - for (const boundary of boundaries) { + await runInSeries(boundaries, async boundary => { await killAt(boundary, join(directory, isolation.replaceAll(' ', '-') + '-' + boundary), isolation) const saved = (await k.schema.hasTable(intent)) ? await k(intent).first() : undefined if (boundary.startsWith('complete-')) assert.equal(saved.complete, boundary === 'complete-after-commit' ? 1 : 0) @@ -253,14 +268,18 @@ async function main() { if (saved) assert.equal(resumed.epoch, saved.epoch) await finish(k) let charged = 0 - for (const table of [ - ...tables, - 'snapshot_profile_keys', - 'snapshot_relation_keys', - 'snapshot_certificate_field_keys', - 'snapshot_global_keys' - ]) - charged += Number((await k(table).count('* AS n').first()).n) + await runInSeries( + [ + ...tables, + 'snapshot_profile_keys', + 'snapshot_relation_keys', + 'snapshot_certificate_field_keys', + 'snapshot_global_keys' + ], + async table => { + charged += Number((await k(table).count('* AS n').first()).n) + } + ) assert.equal((await k('snapshot_journal_bootstrap').first()).rowsUsed, charged) await exact(k) assert.deepEqual(await rows(k), baseline) @@ -274,7 +293,7 @@ async function main() { assert.deepEqual(await rows(k), baseline) await clear(k) console.log(JSON.stringify({ isolation, boundary, status: 'passed' })) - } + }) results.push({ isolation, installedObjects: 60, @@ -285,7 +304,7 @@ async function main() { completionAtomic: true, writerBarrierReleased: true }) - } + }) console.log( JSON.stringify({ status: 'MySQL journal generation', diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs index 51133b616..26c1fc140 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs @@ -1,3 +1,4 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') const receiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } const assert = require('node:assert/strict'), { fork } = require('node:child_process'), @@ -45,7 +46,7 @@ function ownedServer() { assert.equal(actual.HostConfig.Binds, null) assert.equal(actual.HostConfig.Tmpfs['/var/lib/mysql'], 'rw,nosuid,nodev,size=512m') const pid = docker('exec', containerId, 'cat', '/var/lib/mysql/fixture.pid') - assert(/^[0-9]+$/.test(pid) && Number(pid) > 1) + assert(/^\d+$/.test(pid) && Number(pid) > 1) assert.equal(docker('exec', containerId, 'cat', '/proc/' + pid + '/comm'), 'mysqld') return pid } @@ -56,8 +57,11 @@ function crashServer() { } async function ready(previous) { const deadline = Date.now() + 20000 - let last - while (Date.now() < deadline) { + let last, recovered + function* attempts() { + while (Date.now() < deadline && recovered === undefined) yield undefined + } + await runInSeries(attempts(), async () => { try { docker( 'exec', @@ -72,13 +76,14 @@ async function ready(previous) { ) const pid = ownedServer() assert.notEqual(pid, previous) - return pid + recovered = pid } catch (error) { last = error await new Promise(resolve => setTimeout(resolve, 300)) } - } - throw last + }) + if (recovered === undefined) throw last + return recovered } async function isolate(k, isolation) { @@ -88,27 +93,42 @@ async function isolate(k, isolation) { assert.equal(level.isolation.replaceAll('-', ' '), isolation) await k.raw('SET SESSION innodb_lock_wait_timeout=5') } -async function finish(k) { - for (let i = 0; i < 100; i++) { +async function bootstrap(k) { + let complete = false + function* pages() { + for (let i = 0; i < 100 && !complete; i++) yield i + } + await runInSeries(pages(), async () => { const page = await copy(k, 1000000) assert(!page.invalidated) - if (page.complete) return await complete(k, ceiling, receiptPolicy) - assert(i < 99) - } + complete = page.complete + }) + assert(complete, 'Bootstrap did not complete within its bounded fixture pages') +} +async function finish(k) { + await bootstrap(k) + return await complete(k, ceiling, receiptPolicy) } + async function clear(k) { const [triggers] = await k.raw( "SELECT TRIGGER_NAME name FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND LEFT(TRIGGER_NAME,17)='snapshot_journal_'" ) - for (const row of triggers) await k.raw('DROP TRIGGER ??', [row.name]) + await runInSeries(triggers, async row => { + await k.raw('DROP TRIGGER ??', [row.name]) + }) const [names] = await k.raw( "SELECT TABLE_NAME name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,17)='snapshot_journal_'" ) - for (const row of names) await k.schema.dropTable(row.name) + await runInSeries(names, async row => { + await k.schema.dropTable(row.name) + }) } async function rows(k) { const result = {} - for (const name of tables) result[name] = (await k(name).select('*')).map(row => JSON.stringify(row)).sort() + await runInSeries(tables, async name => { + result[name] = (await k(name).select('*')).map(row => JSON.stringify(row)).sort() + }) return result } async function child() { @@ -127,7 +147,7 @@ async function child() { process.kill(process.pid, 'SIGKILL') } } - const object = sql => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_[A-Za-z0-9_]+)/.exec(sql)?.[1] + const object = sql => /^CREATE (?:TABLE|TRIGGER) (snapshot_journal_\w+)/.exec(sql)?.[1] k.on('query', q => { const name = object(q.sql) if (name) park('before-' + name) @@ -150,11 +170,7 @@ async function child() { park('bootstrap-after-commit') } if (boundary.startsWith('complete-')) { - for (let i = 0; i < 100; i++) { - const page = await copy(k, 1000000) - if (page.complete) break - assert(i < 99) - } + await bootstrap(k) completing = true await complete(k, ceiling, receiptPolicy) park('complete-after-commit') @@ -213,8 +229,8 @@ async function main() { 'complete-before-commit', 'complete-after-commit' ] - for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) { - for (const boundary of boundaries) { + await runInSeries(['READ COMMITTED', 'REPEATABLE READ'], async isolation => { + await runInSeries(boundaries, async boundary => { const restarted = await killAt( boundary, join(directory, isolation.replaceAll(' ', '-') + '-' + boundary), @@ -241,14 +257,18 @@ async function main() { assert.equal(resumed.epoch, saved.epoch) await finish(k) let charged = 0 - for (const table of [ - ...tables, - 'snapshot_profile_keys', - 'snapshot_relation_keys', - 'snapshot_certificate_field_keys', - 'snapshot_global_keys' - ]) - charged += Number((await k(table).count('* AS n').first()).n) + await runInSeries( + [ + ...tables, + 'snapshot_profile_keys', + 'snapshot_relation_keys', + 'snapshot_certificate_field_keys', + 'snapshot_global_keys' + ], + async table => { + charged += Number((await k(table).count('* AS n').first()).n) + } + ) assert.equal((await k('snapshot_journal_bootstrap').first()).rowsUsed, charged) await exact(k) assert.deepEqual(await rows(k), baseline) @@ -274,8 +294,8 @@ async function main() { writerRecovered: true }) console.log(JSON.stringify(results.at(-1))) - } - } + }) + }) console.log( JSON.stringify({ status: 'isolated MySQL server-process crash recovery', diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs index fe25a3303..94dcbc3bd 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs @@ -1,3 +1,4 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') const assert = require('node:assert/strict') const { open } = require('./snapshotJournalMysqlConnection.cjs') const { @@ -21,10 +22,18 @@ async function qualify(isolation, bigNumberStrings) { const k = open(bigNumberStrings), peer = open(bigNumberStrings) try { - for (const db of [k, peer]) await db.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) - for (const table of ['snapshot_journal_receipts', 'snapshot_journal_retention', 'receipt_fixture_source']) - await k.schema.dropTableIfExists(table) - for (const sql of snapshotJournalReceiptDdl(k)) await k.raw(sql) + await runInSeries([k, peer], async db => { + await db.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + }) + await runInSeries( + ['snapshot_journal_receipts', 'snapshot_journal_retention', 'receipt_fixture_source'], + async table => { + await k.schema.dropTableIfExists(table) + } + ) + await runInSeries(snapshotJournalReceiptDdl(k), async sql => { + await k.raw(sql) + }) await k('snapshot_journal_retention').insert({ id: 1, floor: '0', receiptLimit: 2, receiptLifetimeMs: 2592000000 }) await k.raw('CREATE TABLE receipt_fixture_source(id INTEGER PRIMARY KEY,value INTEGER NOT NULL) ENGINE=InnoDB') await k('receipt_fixture_source').insert({ id: 1, value: 0 }) @@ -160,63 +169,62 @@ async function processLoss() { directory = await mkdtemp(join(tmpdir(), 'ts569-receipt-loss-')), results = [] try { - for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) - for (const phase of [ - 'record-before-commit', - 'record-after-commit', - 'collect-before-commit', - 'collect-after-commit' - ]) { - await k('snapshot_journal_receipts').delete() - await k('snapshot_journal_retention').update({ floor: '0', receiptLimit: 128, receiptLifetimeMs: 2592000000 }) - const request = { ...processRequest, expiresAt: Date.now() + 60000 }, - marker = join(directory, results.length + '.txt') - if (phase.startsWith('collect')) - await k('snapshot_journal_receipts').insert({ - ...request, - binding: snapshotJournalReceiptBinding(binding), - floor: '0', - expiresAt: 1 + await runInSeries(['READ COMMITTED', 'REPEATABLE READ'], async isolation => { + await runInSeries( + ['record-before-commit', 'record-after-commit', 'collect-before-commit', 'collect-after-commit'], + async phase => { + await k('snapshot_journal_receipts').delete() + await k('snapshot_journal_retention').update({ floor: '0', receiptLimit: 128, receiptLifetimeMs: 2592000000 }) + const request = { ...processRequest, expiresAt: Date.now() + 60000 }, + marker = join(directory, results.length + '.txt') + if (phase.startsWith('collect')) + await k('snapshot_journal_receipts').insert({ + ...request, + binding: snapshotJournalReceiptBinding(binding), + floor: '0', + expiresAt: 1 + }) + const child = fork(__filename, ['child', phase, marker, isolation, String(request.expiresAt)], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + let stderr = '' + child.stderr.on('data', chunk => { + stderr = (stderr + chunk.toString()).slice(-6000) }) - const child = fork(__filename, ['child', phase, marker, isolation, String(request.expiresAt)], { - stdio: ['ignore', 'ignore', 'pipe', 'ipc'] - }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr = (stderr + chunk.toString()).slice(-6000) - }) - const timer = setTimeout(() => child.kill('SIGKILL'), 15000) - let terminal - try { - terminal = await new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', (code, signal) => resolve({ code, signal })) + const timer = setTimeout(() => child.kill('SIGKILL'), 15000) + let terminal + try { + terminal = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + } finally { + clearTimeout(timer) + } + assert.equal(terminal.signal, 'SIGKILL', stderr) + assert.equal(readFileSync(marker, 'utf8'), phase) + const exists = phase === 'record-after-commit' || phase === 'collect-before-commit' + assert.equal((await k('snapshot_journal_receipts')).length, exists ? 1 : 0) + if (phase === 'record-after-commit') + assert.equal( + (await k.transaction(t => readSnapshotJournalReceipt(t, binding, request))).highWater, + request.highWater + ) + if (phase === 'record-before-commit') + await assert.rejects(k.transaction(t => readSnapshotJournalReceipt(t, binding, request))) + await k('receipt_fixture_source') + .where('id', 1) + .update({ value: results.length + 2 }) + results.push({ + isolation, + phase, + signal: terminal.signal, + receiptPersisted: exists, + sourceWriteAfterLoss: true }) - } finally { - clearTimeout(timer) } - assert.equal(terminal.signal, 'SIGKILL', stderr) - assert.equal(readFileSync(marker, 'utf8'), phase) - const exists = phase === 'record-after-commit' || phase === 'collect-before-commit' - assert.equal((await k('snapshot_journal_receipts')).length, exists ? 1 : 0) - if (phase === 'record-after-commit') - assert.equal( - (await k.transaction(t => readSnapshotJournalReceipt(t, binding, request))).highWater, - request.highWater - ) - if (phase === 'record-before-commit') - await assert.rejects(k.transaction(t => readSnapshotJournalReceipt(t, binding, request))) - await k('receipt_fixture_source') - .where('id', 1) - .update({ value: results.length + 2 }) - results.push({ - isolation, - phase, - signal: terminal.signal, - receiptPersisted: exists, - sourceWriteAfterLoss: true - }) - } + ) + }) return results } finally { await k.destroy() @@ -225,8 +233,11 @@ async function processLoss() { } async function main() { const results = [] - for (const isolation of ['READ COMMITTED', 'REPEATABLE READ']) - for (const bigNumberStrings of [false, true]) results.push(await qualify(isolation, bigNumberStrings)) + await runInSeries(['READ COMMITTED', 'REPEATABLE READ'], async isolation => { + await runInSeries([false, true], async bigNumberStrings => { + results.push(await qualify(isolation, bigNumberStrings)) + }) + }) const crashes = await processLoss() console.log( JSON.stringify({ diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs index c671c413b..cd52e2924 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs @@ -1,3 +1,4 @@ +const { runInSeries } = require('../../out/src/utility/runInSeries.js') const receiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 2592000000 } const assert = require('node:assert/strict'), { fork } = require('node:child_process'), @@ -23,9 +24,16 @@ const open = filename => pool: { min: 1, max: 1 } }) const finish = async k => { - for (let page = 0; page < 100; page++) if ((await copy(k, 1000000)).complete) return - throw new Error('Bootstrap incomplete') + let complete = false + function* pages() { + for (let page = 0; page < 100 && !complete; page++) yield page + } + await runInSeries(pages(), async () => { + complete = (await copy(k, 1000000)).complete + }) + if (!complete) throw new Error('Bootstrap incomplete') } + async function child() { process.once('disconnect', () => process.exit(1)) const childDeadline = setTimeout(() => process.exit(1), 20000) @@ -92,85 +100,96 @@ async function main() { const directory = await mkdtemp(join(tmpdir(), 'ts569-journal-generation-kill-')), results = [] try { - for (const boundary of [ - 'install-after-ddl', - 'install-after-generation', - 'install-after-bootstrap', - 'install-after-retention', - 'install-after-commit', - 'bootstrap-after-budget-bind', - 'bootstrap-after-metadata', - 'bootstrap-after-progress', - 'bootstrap-after-commit', - 'complete-before-state', - 'complete-after-state', - 'complete-after-commit' - ]) { - const filename = join(directory, boundary + '.sqlite'), - k = open(filename), - source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) - try { - await k.raw('PRAGMA journal_mode=WAL') - await source.migrate('journal generation process-loss fixture', 'synthetic-source') - await source.makeAvailable() - const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)), - { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) - await seedArchiveClosure(source, user.userId, other.userId) - const original = {} - for (const table of tables) original[table] = await k(table) - if (boundary.startsWith('bootstrap-')) await install(k, '1000000', receiptPolicy) - if (boundary.startsWith('complete-')) { + await runInSeries( + [ + 'install-after-ddl', + 'install-after-generation', + 'install-after-bootstrap', + 'install-after-retention', + 'install-after-commit', + 'bootstrap-after-budget-bind', + 'bootstrap-after-metadata', + 'bootstrap-after-progress', + 'bootstrap-after-commit', + 'complete-before-state', + 'complete-after-state', + 'complete-after-commit' + ], + async boundary => { + const filename = join(directory, boundary + '.sqlite'), + k = open(filename), + source = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + try { + await k.raw('PRAGMA journal_mode=WAL') + await source.migrate('journal generation process-loss fixture', 'synthetic-source') + await source.makeAvailable() + const { user } = await source.findOrInsertUser('02' + '11'.repeat(32)), + { user: other } = await source.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(source, user.userId, other.userId) + const original = {} + await runInSeries(tables, async table => { + original[table] = await k(table) + }) + if (boundary.startsWith('bootstrap-')) await install(k, '1000000', receiptPolicy) + if (boundary.startsWith('complete-')) { + await install(k, '1000000', receiptPolicy) + await finish(k) + } + const killed = await killAt(filename, boundary) + await k.transaction(async t => { + await t('snapshot_index_generation_v2') + .where('id', 0) + .update({ complete: t.ref('complete') }) + }) + const objects = await k('sqlite_master').whereRaw('lower(substr(name,1,17))=?', ['snapshot_journal_']) + const committed = boundary.endsWith('after-commit') + if (boundary.startsWith('install-')) assert.equal(objects.length, committed ? 61 : 0) + else if (boundary.startsWith('bootstrap-')) { + assert.equal((await read(k, receiptPolicy)).complete, false) + const progress = await k('snapshot_journal_bootstrap').first() + assert.equal(progress.rowsUsed, committed ? original.transactions.length : 0) + assert.equal(progress.rowLimit, committed ? 1000000 : null) + assert.equal( + progress.cursor, + committed ? JSON.stringify([Math.max(...original.transactions.map(row => row.transactionId))]) : null + ) + } else assert.equal((await read(k, receiptPolicy)).complete, committed) await install(k, '1000000', receiptPolicy) await finish(k) - } - const killed = await killAt(filename, boundary) - await k.transaction(async t => { - await t('snapshot_index_generation_v2') - .where('id', 0) - .update({ complete: t.ref('complete') }) - }) - const objects = await k('sqlite_master').whereRaw('lower(substr(name,1,17))=?', ['snapshot_journal_']) - const committed = boundary.endsWith('after-commit') - if (boundary.startsWith('install-')) assert.equal(objects.length, committed ? 61 : 0) - else if (boundary.startsWith('bootstrap-')) { - assert.equal((await read(k, receiptPolicy)).complete, false) - const progress = await k('snapshot_journal_bootstrap').first() - assert.equal(progress.rowsUsed, committed ? original.transactions.length : 0) - assert.equal(progress.rowLimit, committed ? 1000000 : null) - assert.equal( - progress.cursor, - committed ? JSON.stringify([Math.max(...original.transactions.map(row => row.transactionId))]) : null + await complete(k, receiptPolicy) + assert.equal((await read(k, receiptPolicy)).complete, true) + await exact(k) + let charged = 0 + await runInSeries( + [ + ...tables, + 'snapshot_profile_keys_v2', + 'snapshot_relation_keys_v2', + 'snapshot_certificate_field_keys_v2', + 'snapshot_global_keys_v2' + ], + async table => { + charged += Number((await k(table).count('* AS n').first()).n) + } ) - } else assert.equal((await read(k, receiptPolicy)).complete, committed) - await install(k, '1000000', receiptPolicy) - await finish(k) - await complete(k, receiptPolicy) - assert.equal((await read(k, receiptPolicy)).complete, true) - await exact(k) - let charged = 0 - for (const table of [ - ...tables, - 'snapshot_profile_keys_v2', - 'snapshot_relation_keys_v2', - 'snapshot_certificate_field_keys_v2', - 'snapshot_global_keys_v2' - ]) - charged += Number((await k(table).count('* AS n').first()).n) - assert.equal((await k('snapshot_journal_bootstrap').first()).rowsUsed, charged) - for (const table of tables) assert.deepEqual(await k(table), original[table]) - assert.deepEqual(await k.raw('PRAGMA foreign_key_check'), []) - results.push({ - boundary, - signal: killed.signal, - atomicity: committed ? 'committed' : 'rolled back', - writerLockReleased: true, - sourcePreserved: true, - resumedComplete: true - }) - } finally { - await source.destroy() + assert.equal((await k('snapshot_journal_bootstrap').first()).rowsUsed, charged) + await runInSeries(tables, async table => { + assert.deepEqual(await k(table), original[table]) + }) + assert.deepEqual(await k.raw('PRAGMA foreign_key_check'), []) + results.push({ + boundary, + signal: killed.signal, + atomicity: committed ? 'committed' : 'rolled back', + writerLockReleased: true, + sourcePreserved: true, + resumedComplete: true + }) + } finally { + await source.destroy() + } } - } + ) console.log( JSON.stringify({ status: 'SQLite journal generation native WAL process-loss checks', diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotSqliteGenerationCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotSqliteGenerationCrash.cjs index c6a35c365..3c22f4d96 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotSqliteGenerationCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotSqliteGenerationCrash.cjs @@ -243,7 +243,9 @@ async function qualifySQLiteGenerationProcessLoss() { actual.map(({ snapshotRowId, snapshotUserId }) => ({ snapshotRowId, snapshotUserId })), expected ) - for (const table of retiredTables) assert.equal(await k.schema.hasTable(table), false) + await runInSeries(retiredTables, async table => { + assert.equal(await k.schema.hasTable(table), false) + }) assert.equal((await k('knex_migrations').where('name', migration)).length, 1) assert.equal((await k.raw('PRAGMA foreign_keys'))[0].foreign_keys, 1) results.push({ diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteFixtures.ts index aa4d0d364..3a0470aff 100644 --- a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteFixtures.ts +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteFixtures.ts @@ -1,3 +1,4 @@ +import { runInSeries } from '../../src/utility/runInSeries' import { knex, type Knex } from 'knex' import { installMembershipDraft } from './snapshotSqliteMaintenanceFixture' import { addSnapshotProfileIndexes } from '../../src/storage/schema/snapshotProfileIndexMigration' @@ -46,8 +47,9 @@ export async function fixture(collation: string, recursive: boolean, corrected = try { await k.raw('PRAGMA recursive_triggers=' + Number(recursive)) if (filename !== ':memory:') await k.raw('PRAGMA journal_mode=WAL') - for (const sql of schema) + await runInSeries(schema, async sql => { await k.raw('CREATE TABLE ' + sql.replaceAll(/VARCHAR\(\d+\)/g, type => type + ' COLLATE ' + collation)) + }) await k.schema.alterTable('transactions', table => { void table.index('txid') }) @@ -89,9 +91,10 @@ export async function exact(k: Knex) { snapshotUserId: number snapshotRowId: number }> = [] - for (const [id, [table, key]] of profiles.entries()) - for (const row of await k(table)) - expected.push({ snapshotTableId: id, snapshotUserId: row.userId, snapshotRowId: row[key] }) + await runInSeries(profiles.entries(), async ([id, [table, key]]) => { + const rows = await k(table) + for (const row of rows) expected.push({ snapshotTableId: id, snapshotUserId: row.userId, snapshotRowId: row[key] }) + }) expected.sort( (a, b) => a.snapshotTableId - b.snapshotTableId || a.snapshotUserId - b.snapshotUserId || a.snapshotRowId - b.snapshotRowId diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteIdentityFixture.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteIdentityFixture.ts index 604f96832..03fcadc3e 100644 --- a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteIdentityFixture.ts +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteIdentityFixture.ts @@ -1,3 +1,4 @@ +import { runInSeries } from '../../src/utility/runInSeries' import type { Knex } from 'knex' import type { IdentityDefinition } from '../../src/storage/schema/snapshotSqliteIdentity' import { WERR_INVALID_OPERATION } from '../../src/sdk/WERR_errors' @@ -18,9 +19,9 @@ export async function copyIdentityPage( .where(identity.source.key, '>', after) .orderBy(identity.source.key) .limit(count) - for (const row of rows) { + await runInSeries(rows, async row => { const primary = [identity.source.key, ...(identity.source.owner ? [identity.source.owner] : [])] await k(identity.table).insert(row).onConflict(primary).merge() - } + }) return rows.length === count ? Number(rows.at(-1)[identity.source.key]) : undefined } diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteMaintenanceFixture.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteMaintenanceFixture.ts index b6c58842f..483e17c01 100644 --- a/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteMaintenanceFixture.ts +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotSqliteMaintenanceFixture.ts @@ -1,12 +1,19 @@ +import { runInSeries } from '../../src/utility/runInSeries' import type { Knex } from 'knex' import { readIdentity, identityDDL, type IdentityDefinition } from '../../src/storage/schema/snapshotSqliteIdentity' import { numeric, relations, membershipTriggers } from '../../src/storage/schema/snapshotSqliteMembership' export async function installMembershipDraft(k: Knex): Promise { const definitions: IdentityDefinition[] = [] - for (const source of numeric) definitions.push(await readIdentity(k, source)) + await runInSeries(numeric, async source => { + definitions.push(await readIdentity(k, source)) + }) await k.transaction(async trx => { - for (const definition of definitions) for (const ddl of identityDDL(definition)) await trx.raw(ddl) + await runInSeries(definitions, async definition => { + await runInSeries(identityDDL(definition), async ddl => { + await trx.raw(ddl) + }) + }) const sources = [ ...numeric.map(source => source.table), ...relations.map(relation => relation.table), @@ -16,9 +23,12 @@ export async function installMembershipDraft(k: Knex): Promise { .where('type', 'trigger') .whereIn('tbl_name', sources) .select('name') - for (const trigger of old) + await runInSeries(old, async trigger => { if (/^snapshot_(profile|relation|certificate|global)_/.test(trigger.name)) await trx.raw('DROP TRIGGER ??', [trigger.name]) - for (const sql of membershipTriggers(definitions)) await trx.raw(sql) + }) + await runInSeries(membershipTriggers(definitions), async sql => { + await trx.raw(sql) + }) }) } From 91f1d597304af2581050ad94e18abe8122e4cd70 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 10:11:42 -0700 Subject: [PATCH 090/127] Validate journal receipt boundaries and resolve analyzer findings --- .../journal/SnapshotJournalBootstrap.ts | 37 ++-- .../journal/SnapshotJournalReceipt.test.ts | 165 ++++++++++++++++++ .../journal/SnapshotJournalReceipt.ts | 13 +- .../SnapshotJournalReceiptMysql.test.ts | 107 +++++++++++- .../storage/snapshotJournalReceiptMysql.cjs | 6 +- 5 files changed, 299 insertions(+), 29 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts index 1b6bc60de..14dee4808 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalBootstrap.ts @@ -230,21 +230,21 @@ function projectPage(k: Knex, query: Knex.QueryBuilder, stream: Stream, local: b } if (stream.extra) query.select('s.' + stream.extra) } -function afterCursor(query: Knex.QueryBuilder, stream: Stream, cursor: Array, local: boolean): void { - if (local) - query.whereRaw('(' + stream.keys.map(() => '??').join(',') + ') > (' + stream.keys.map(() => '?').join(',') + ')', [ - ...stream.keys.map(key => 's.' + key), - ...cursor - ]) - else - query.where(function () { - stream.keys.forEach((key, index) => { - this.orWhere(function () { - for (let i = 0; i < index; i++) this.where('s.' + stream.keys[i], cursor[i]) - this.where('s.' + key, '>', cursor[index]) - }) +function afterSqliteCursor(query: Knex.QueryBuilder, stream: Stream, cursor: Array): void { + query.whereRaw('(' + stream.keys.map(() => '??').join(',') + ') > (' + stream.keys.map(() => '?').join(',') + ')', [ + ...stream.keys.map(key => 's.' + key), + ...cursor + ]) +} +function afterMysqlCursor(query: Knex.QueryBuilder, stream: Stream, cursor: Array): void { + query.where(function () { + stream.keys.forEach((key, index) => { + this.orWhere(function () { + for (let i = 0; i < index; i++) this.where('s.' + stream.keys[i], cursor[i]) + this.where('s.' + key, '>', cursor[index]) }) }) + }) } async function queryPage( k: Knex, @@ -255,7 +255,10 @@ async function queryPage( const query = local ? k.from({ s: stream.table }) : (await mysqlSourceQuery(k, stream)).query projectPage(k, query, stream, local) query.orderBy(stream.keys.map(key => 's.' + key)).limit(256) - if (cursor) afterCursor(query, stream, cursor, local) + if (cursor) { + if (local) afterSqliteCursor(query, stream, cursor) + else afterMysqlCursor(query, stream, cursor) + } const sql = query.toSQL() const plan = await k.raw((local ? 'EXPLAIN QUERY PLAN ' : 'EXPLAIN ') + sql.sql, sql.bindings as Knex.RawBinding[]) if ( @@ -358,7 +361,11 @@ export async function copySnapshotJournalBootstrapPage( k: Knex, rowLimit: number ): Promise { - if (k.isTransaction || !validSnapshotJournalBootstrapBudget({ rowLimit, rowsUsed: 0 }) || rowLimit === null) + if ( + k.isTransaction || + !Number.isSafeInteger(rowLimit) || + !validSnapshotJournalBootstrapBudget({ rowLimit, rowsUsed: 0 }) + ) return invalid() const local = sqlite(k), all = streams(local) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts index 1c9bb1167..31951774e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts @@ -1,6 +1,9 @@ import { knex, type Knex } from 'knex' import { createHash } from 'node:crypto' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import * as ArchiveClock from '../archive/SnapshotArchiveSql' import { + snapshotJournalReceiptPolicy, snapshotJournalReceiptBinding, snapshotJournalReceiptDdl, recordSnapshotJournalReceipt, @@ -247,3 +250,165 @@ test('a receipt whose captured floor exceeds the current floor refuses inconsist await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() }) + +test('policy rejects non-record inputs with the stable receipt error identity', () => { + const fields = { receiptLimit: 1, receiptLifetimeMs: 1 } + const malformed: unknown[] = [null, undefined, 1, 'policy', Object.assign([], fields), Object.assign(() => 0, fields)] + for (const value of malformed) { + const call = () => snapshotJournalReceiptPolicy(value as typeof fields) + expect(call).toThrow(WERR_INVALID_OPERATION) + expect(call).toThrow('Invalid, unavailable or expired snapshot journal receipt') + } + expect(snapshotJournalReceiptPolicy(fields)).toEqual(fields) + expect(snapshotJournalReceiptPolicy(fields)).not.toBe(fields) + expect(snapshotJournalReceiptPolicy({ receiptLimit: 128, receiptLifetimeMs: 2592000000 })).toEqual({ + receiptLimit: 128, + receiptLifetimeMs: 2592000000 + }) +}) + +test('binding refuses non-record values and textual lookalikes before hashing', () => { + for (const value of [null, undefined, 1, 'binding', Object.assign(() => 0, binding)]) + expect(() => snapshotJournalReceiptBinding(value as unknown as SnapshotJournalReceiptBinding)).toThrow( + WERR_INVALID_OPERATION + ) + for (const field of ['backend', 'source', 'schema', 'epoch', 'identityKey', 'storageIdentity'] as const) { + const text = binding[field] + expect(() => snapshotJournalReceiptBinding({ ...binding, [field]: { toString: () => text } })).toThrow( + WERR_INVALID_OPERATION + ) + } + for (const field of ['epoch', 'identityKey'] as const) + for (const text of ['x' + binding[field], binding[field] + 'x']) + expect(() => snapshotJournalReceiptBinding({ ...binding, [field]: text })).toThrow(WERR_INVALID_OPERATION) +}) + +test('all supported chains and exact UTF-8 storage identity boundaries bind distinctly', () => { + const chains = ['main', 'test', 'stn', 'ttn', 'tstn', 'mock'] + expect(new Set(chains.map(chain => snapshotJournalReceiptBinding({ ...binding, chain }))).size).toBe(chains.length) + const identities = ['x', 'x'.repeat(256), 'é'.repeat(128), '😀'.repeat(64)] + const hashes = identities.map(storageIdentity => snapshotJournalReceiptBinding({ ...binding, storageIdentity })) + expect(new Set(hashes).size).toBe(identities.length) + for (const hash of hashes) expect(hash).toMatch(/^[0-9a-f]{64}$/) +}) + +test('exact floor and lifetime boundaries remain readable until the database expiry instant', async () => { + let time = 1000000 + const clock = jest.spyOn(ArchiveClock, 'snapshotArchiveDatabaseNow').mockImplementation(async () => time) + try { + const input = { ...request(1), highWater: snapshotJournalRevision('7'), expiresAt: time + 1000 } + await k('snapshot_journal_retention').update({ floor: '7', receiptLifetimeMs: 1000 }) + const expected = { ...input, binding: snapshotJournalReceiptBinding(binding), floor: '7' } + expect(await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input))).toEqual(expected) + expect(await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input))).toEqual(expected) + expect(await k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).toEqual(expected) + for (const changed of [ + { ...input, highWater: snapshotJournalRevision('8') }, + { ...input, expiresAt: input.expiresAt + 1 } + ]) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, changed))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + time = input.expiresAt - 1 + expect(await k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).toEqual(expected) + expect(await k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(0) + time = input.expiresAt + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, input))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + expect(await k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(1) + expect(await k('snapshot_journal_receipts')).toEqual([]) + } finally { + clock.mockRestore() + } +}) + +test.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( + 'invalid database time %s refuses allocation, lookup and collection without changing rows', + async time => { + const input = request(1) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + const before = await k('snapshot_journal_receipts') + const clock = jest.spyOn(ArchiveClock, 'snapshotArchiveDatabaseNow').mockResolvedValue(time) + try { + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, request(2)))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + await expect(k.transaction(t => collectSnapshotJournalReceipts(t))).rejects.toThrow(WERR_INVALID_OPERATION) + expect(await k('snapshot_journal_receipts')).toEqual(before) + } finally { + clock.mockRestore() + } + } +) + +test('malformed singleton retention metadata refuses every operation without partial writes', async () => { + const input = request(1) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + const retained = await k('snapshot_journal_retention').first() + const before = await k('snapshot_journal_receipts') + await k.raw('PRAGMA ignore_check_constraints=ON') + const malformed = [ + [], + [{ ...retained, id: 2 }], + [retained, { ...retained, id: 2 }], + [{ ...retained, receiptLimit: 0 }], + [{ ...retained, receiptLimit: 129 }], + [{ ...retained, receiptLifetimeMs: 0 }], + [{ ...retained, receiptLifetimeMs: 2592000001 }] + ] + for (const rows of malformed) { + await k('snapshot_journal_retention').delete() + if (rows.length) await k('snapshot_journal_retention').insert(rows) + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, request(2)))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + await expect(k.transaction(t => collectSnapshotJournalReceipts(t))).rejects.toThrow(WERR_INVALID_OPERATION) + expect(await k('snapshot_journal_receipts')).toEqual(before) + } +}) + +test('malformed stored receipt revisions and collector identities refuse without deleting data', async () => { + const input = request(1) + await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input)) + const stored = await k('snapshot_journal_receipts').first() + for (const highWater of ['0', '-1', '9223372036854775808']) { + await k('snapshot_journal_receipts').update({ highWater }) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() + await expect(k.transaction(t => recordSnapshotJournalReceipt(t, binding, input))).rejects.toThrow() + } + await k('snapshot_journal_receipts').update({ ...stored, requestId: 'not-a-digest', expiresAt: 1 }) + await expect(k.transaction(t => collectSnapshotJournalReceipts(t))).rejects.toThrow(WERR_INVALID_OPERATION) + expect(await k('snapshot_journal_receipts')).toHaveLength(1) +}) + +test('SQLite alias is supported while unrecognized database drivers refuse explicitly', async () => { + k.client.config.client = 'sqlite3' + const input = request(1) + expect(await k.transaction(t => recordSnapshotJournalReceipt(t, binding, input))).toMatchObject(input) + for (const client of ['pg', 'mysql-compatible', '', undefined]) { + k.client.config.client = client + expect(() => snapshotJournalReceiptDdl(k)).toThrow(WERR_INVALID_OPERATION) + await expect(k.transaction(t => readSnapshotJournalReceipt(t, binding, input))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + } +}) + +test('request expiry must be an exact primitive number before database work', async () => { + const input = request(1) + for (const expiresAt of [String(input.expiresAt), { valueOf: () => input.expiresAt }, null, undefined, Number.NaN]) + await expect( + k.transaction(t => recordSnapshotJournalReceipt(t, binding, { ...input, expiresAt } as typeof input)) + ).rejects.toThrow(WERR_INVALID_OPERATION) + expect(await k('snapshot_journal_receipts')).toEqual([]) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts index 95f3e2f44..5af022a73 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts @@ -53,9 +53,13 @@ function transaction(k: Knex): void { const boundedInteger = (value: unknown, max: number): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= max +function isObject(value: unknown): value is object { + return value !== null && typeof value === 'object' +} + /** Detached installation policy; a generation cannot silently change it on resume. */ export function snapshotJournalReceiptPolicy(value: SnapshotJournalReceiptPolicy): SnapshotJournalReceiptPolicy { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return invalid() + if (!isObject(value) || Array.isArray(value)) return invalid() const { receiptLimit, receiptLifetimeMs } = value if ( !boundedInteger(receiptLimit, SNAPSHOT_JOURNAL_RECEIPT_LIMIT) || @@ -68,8 +72,7 @@ export function snapshotJournalReceiptPolicy(value: SnapshotJournalReceiptPolicy /** An exact, bounded digest binds a receipt without retaining profile metadata in it. */ export function snapshotJournalReceiptBinding(value: SnapshotJournalReceiptBinding): string { if ( - value === null || - typeof value !== 'object' || + !isObject(value) || ![value.backend, value.source, value.schema].every(part => typeof part === 'string' && digestPattern.test(part)) || typeof value.epoch !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.epoch) || @@ -225,7 +228,7 @@ export async function recordSnapshotJournalReceipt( } // Capacity includes expired rows until a bounded collector actually commits deletion. const occupiedQuery = k('snapshot_journal_receipts') - .select(k.raw('1 AS occupied')) + .select({ occupied: 1 }) .orderBy('requestId') .limit(state.receiptLimit + 1) if (!local(k)) occupiedQuery.forUpdate().noWait() @@ -246,7 +249,7 @@ export async function readSnapshotJournalReceipt( const requested = receipt({ ...expected, binding: bound, floor: '0' }) const state = await retention(k, false), time = await now(k) - const [stored] = await sharedRows(k, receiptQuery(k).where('requestId', requested.requestId).limit(1)) + const stored = (await sharedRows(k, receiptQuery(k).where('requestId', requested.requestId).limit(1))).at(0) if (stored === undefined) return invalid() const result = receipt(stored, true) if ( diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts index d41161751..e4397576f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceiptMysql.test.ts @@ -1,4 +1,5 @@ import { knex, type Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' import { snapshotJournalReceiptDdl, snapshotJournalReceiptBinding, @@ -38,6 +39,15 @@ function stringifyBigints(rows: Array>): void { if (row[field] !== undefined) row[field] = String(row[field]) } +function overrideBigints( + rows: Array>, + values: Partial> +): void { + for (const row of rows) + for (const field of ['expiresAt', 'receiptLifetimeMs'] as const) + if (row[field] !== undefined && Object.hasOwn(values, field)) row[field] = values[field] +} + // Real MySQL query compilation/response processing, backed by SQLite for DML // and rollback. The independent native fixture proves MySQL isolation/locks. async function fixture(strings: boolean) { @@ -46,7 +56,11 @@ async function fixture(strings: boolean) { for (const sql of snapshotJournalReceiptDdl(db)) await db.raw(sql) await db('snapshot_journal_retention').insert({ id: 1, floor: '0', receiptLimit: 2, receiptLifetimeMs: 2592000000 }) const queries: Query[] = [], - state = { failLock: false, now: time } + state = { + failLock: false, + now: time, + storedBigints: {} as Partial> + } const connection = { __knexUid: 'receipt-driver', query: ( @@ -76,6 +90,7 @@ async function fixture(strings: boolean) { const result = await db.raw(sql, q.bindings) if (Array.isArray(result)) { if (strings) stringifyBigints(result) + overrideBigints(result, state.storedBigints) return respond(result) } return respond({ affectedRows: result?.changes ?? 0, insertId: result?.lastInsertRowid ?? 0 }) @@ -146,11 +161,10 @@ test('MySQL expired capacity remains charged until collection commits', async () expect(await f.k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(1) await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, c)) expect(await f.k.transaction(t => readSnapshotJournalReceipt(t, binding, b))).toMatchObject(b) - expect( - f.queries - .filter(q => q.sql.startsWith('select 1 AS occupied')) - .every(q => /limit \? for update nowait$/i.test(q.sql)) - ).toBe(true) + const capacityQueries = f.queries.filter(q => q.sql.startsWith('select 1 as `occupied`')) + expect(capacityQueries.length).toBeGreaterThan(0) + expect(capacityQueries.every(q => /order by `requestId` asc limit \? for update nowait$/i.test(q.sql))).toBe(true) + expect(capacityQueries.every(q => q.bindings.at(-1) === 3)).toBe(true) expect( f.queries.some( q => @@ -218,3 +232,84 @@ test('MySQL receipt tables carry the expiry index in the atomic table definition await k.destroy() } }) + +test('MySQL legacy alias retains current-read receipt and retention semantics', async () => { + const f = await fixture(true) + try { + f.k.client.config.client = 'mysql' + const a = input(1) + expect(snapshotJournalReceiptDdl(f.k)).toHaveLength(2) + await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)) + expect(await f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).toMatchObject(a) + f.state.now = a.expiresAt + expect(await f.k.transaction(t => collectSnapshotJournalReceipts(t))).toBe(1) + expect(await f.db('snapshot_journal_receipts')).toEqual([]) + } finally { + await f.close() + } +}) + +test('MySQL driver BIGINT responses reject noncanonical strings and nonprimitive numeric lookalikes', async () => { + const f = await fixture(true) + try { + const a = input(1) + await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)) + for (const field of ['expiresAt', 'receiptLifetimeMs'] as const) { + const value = field === 'expiresAt' ? a.expiresAt : 2592000000 + const invalid: unknown[] = [ + '0' + value, + '+' + value, + ' ' + value, + value + ' ', + value / 1000 + 'e3', + value + '.0', + 0, + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + Number.NaN, + Number.POSITIVE_INFINITY, + { valueOf: () => value, toString: () => String(value) } + ] + for (const response of invalid) { + f.state.storedBigints = { [field]: response } + await expect(f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + await expect(f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, a))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + } + f.state.storedBigints = { [field]: String(value) } + expect(await f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).toMatchObject(a) + f.state.storedBigints = {} + } + expect(await f.db('snapshot_journal_receipts')).toHaveLength(1) + } finally { + await f.close() + } +}) + +test('MySQL receipt reads acquire shared nonwaiting locks and preserve the recorded expiry', async () => { + const f = await fixture(false) + try { + const a = input(1) + await f.k.transaction(t => recordSnapshotJournalReceipt(t, binding, a)) + f.queries.length = 0 + expect(await f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).toMatchObject(a) + const retentionReads = f.queries.filter(q => q.sql.includes('from `snapshot_journal_retention`')) + expect(retentionReads).toHaveLength(1) + expect(retentionReads[0].sql.endsWith('FOR SHARE NOWAIT')).toBe(true) + await f.db('snapshot_journal_receipts').update({ expiresAt: a.expiresAt + 1 }) + await expect(f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + await f.db('snapshot_journal_receipts').update({ expiresAt: a.expiresAt }) + f.state.now = a.expiresAt + await expect(f.k.transaction(t => readSnapshotJournalReceipt(t, binding, a))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + } finally { + await f.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs index 94dcbc3bd..bbd227d80 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs @@ -53,7 +53,7 @@ async function qualify(isolation, bigNumberStrings) { await assert.rejects( k.transaction(async t => { await recordSnapshotJournalReceipt(t, binding, b) - throw Error('before-commit') + throw new Error('before-commit') }), /before-commit/ ) @@ -61,7 +61,7 @@ async function qualify(isolation, bigNumberStrings) { await assert.rejects( (async () => { await k.transaction(t => recordSnapshotJournalReceipt(t, binding, b)) - throw Error('lost-ack') + throw new Error('lost-ack') })(), /lost-ack/ ) @@ -159,7 +159,7 @@ async function crashChild() { if (phase.endsWith('before-commit')) die() }) if (phase.endsWith('after-commit')) die() - throw Error('Unreached owned crash boundary') + throw new Error('Unreached owned crash boundary') } finally { await k.destroy() } From ceb6718414903dbf9981e16c6ebc8ae8beb87107 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 14:08:37 -0700 Subject: [PATCH 091/127] Capture coherent journal views with committed receipts and owned native cleanup --- docs/guides/wallet-sync-reliability.md | 35 +- docs/reference/package-api-migrations.md | 74 ++-- governance/mutation-testing/targets.mjs | 26 +- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 14 +- packages/wallet/wallet-toolbox/README.md | 10 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 84 +++- .../storage/snapshot/RetainedReadSnapshot.ts | 4 +- .../archive/KnexSnapshotArchiveSource.ts | 129 +++--- .../journal/SnapshotJournalCapture.test.ts | 414 ++++++++++++++++++ .../journal/SnapshotJournalCapture.ts | 289 ++++++++++++ .../SnapshotJournalCaptureBackend.test.ts | 309 +++++++++++++ .../journal/SnapshotJournalCaptureBackend.ts | 135 ++++++ .../SnapshotJournalCaptureFence.test.ts | 143 ++++++ .../journal/SnapshotJournalCaptureFence.ts | 98 +++++ .../SnapshotJournalConnections.test.ts | 367 ++++++++++++++++ .../journal/SnapshotJournalConnections.ts | 72 +++ .../test/storage/runSnapshotJournalMysql.cjs | 5 +- .../storage/snapshotJournalCaptureMysql.cjs | 218 +++++++++ .../snapshotJournalCaptureMysqlChild.cjs | 24 + .../snapshotJournalCaptureProcessLoss.cjs | 48 ++ .../storage/snapshotJournalCaptureSqlite.cjs | 166 +++++++ .../test/storage/snapshotJournalMysql.cjs | 2 +- .../snapshotJournalMysqlServerCrash.cjs | 2 +- .../storage/snapshotJournalReceiptMysql.cjs | 2 +- .../storage/snapshotJournalSqliteCrash.cjs | 3 +- scripts/mutation-partitions.mjs | 6 +- scripts/mutation-partitions.test.mjs | 6 +- scripts/mutation-testing.test.mjs | 10 +- specs/wallet/sync-portability-program.md | 16 + 30 files changed, 2587 insertions(+), 128 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysqlChild.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureProcessLoss.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 5ccaae648..f304a36a3 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -1084,20 +1084,41 @@ indexed collection removes at most 64 records. Stored text projections and capacity probes are bounded. Receipt record/collection process-loss fixtures check state before and after commit under both MySQL isolation levels. -A caller must reserve the reader before taking the short writer barrier, pin its -view before recording the proof, and publish only after receipt commit. That -capture controller and atomic floor/tombstone lifecycle are still unfinished. -A durable prefix proof does not reopen a killed database read transaction. +The internal `StorageKnex.openSnapshotJournalSource` controller reserves two +owned one-slot pools before taking the nonwaiting writer barrier. Fresh +`better-sqlite3` pools bind one existing file-backed WAL database; `mysql2` pools +bind the actual matching server UUID and database returned by both native +connections. Dynamic/external pools and other drivers refuse before construction. +The controller pins generation, profile, schema and all thirteen standard tables, +records a fresh request-bound prefix receipt, and awaits both commit and the +transaction's completion promise. Closure verification runs after the writer +barrier is released and before publication. Foreground writers can progress while +that verification or later reads run. + +Cancellation rejects opening promptly while the provider retains its source +admission until pending work and physical cleanup drain. Provider destruction +closes an active view. Unproved native cleanup permanently fences further source +admission and remains observable through cleanup and destruction. Native WAL and +MySQL fixtures terminate the source process before receipt commit, after durable +commit and after publication, then check receipt atomicity, new capture and writer +progress. MySQL additionally exercises READ COMMITTED and REPEATABLE READ, busy +barrier refusal and native provider shutdown. These are local synthetic engine +proofs; exact-head hosted, deployed/PXC and power-loss acceptance remain separate. + +Atomic floor/tombstone lifecycle and generation-aware delta-page/receiver +integration remain unfinished. A durable prefix proof does not reopen a killed +database read transaction. This foundation adds no registered migration, public capability or reader advertisement. Its event-window invalidation is not a complete retention or -resource policy. Full quotas, capture publication and continuity-floor ownership, +resource policy. Full quotas and continuity-floor ownership, generation-aware receiver/primary integration, and remaining remote/IndexedDB, streaming and staged-import acceptance remain unfinished. Do not infer full incremental continuity or completed issue #544 from these helpers. -The wallet-snapshot-journal mutation target owns all fourteen complete source -modules in twelve execution parts, including the whole receipt module. Every part retains the complete canonical +The wallet-snapshot-journal mutation target owns all eighteen complete source +modules in sixteen execution parts, including the whole receipt, capture, native +backend, connection ownership and barrier modules. Every part retains the complete canonical journal tests and fixtures, including one governed property entry for exact revision/page, generated source-observer and committed receipt-state schedules. A minimum score of 90%, zero uncovered/invalid mutants, 300 cases with seed 3242026, four workers, runner reuse 8 and 90-minute diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 9df16476d..cc5481c9e 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. +- Release note: Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index b4def3c0a..fea5a8271 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -461,6 +461,20 @@ export function buildMutationTargets(repositoryRoot) { 'override supportsRetainedReadSnapshot(): boolean', 'private async readMySQLSnapshot' ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'async openSnapshotJournalSource(', + 'recoverSnapshotArchiveSources(): Promise' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override async destroy(): Promise', + 'override async migrate(' + ), sourceLineRange( repositoryRoot, 'packages/wallet/wallet-toolbox', @@ -480,6 +494,8 @@ export function buildMutationTargets(repositoryRoot) { 'jest.config.cjs', [ '/src/storage/snapshot/*.test.ts', + '/src/storage/snapshot/journal/SnapshotJournalCapture*.test.ts', + '/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts', '/src/storage/__test/StorageKnexMigrationFailure.security.test.ts' ], { @@ -510,6 +526,10 @@ export function buildMutationTargets(repositoryRoot) { 'test/storage/snapshotJournalMysql.cjs', 'test/storage/snapshotJournalMysqlServerCrash.cjs', 'test/storage/snapshotJournalReceiptMysql.cjs', + 'test/storage/snapshotJournalCaptureMysql.cjs', + 'test/storage/snapshotJournalCaptureMysqlChild.cjs', + 'test/storage/snapshotJournalCaptureProcessLoss.cjs', + 'test/storage/snapshotJournalCaptureSqlite.cjs', 'test/storage/snapshotJournalSqliteCrash.cjs', 'test/storage/runSnapshotJournalMysql.cjs', 'test/storage/snapshotArchiveDocker.cjs' @@ -530,7 +550,11 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', 'src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', 'src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts', - 'src/storage/snapshot/journal/SnapshotJournalReceipt.ts' + 'src/storage/snapshot/journal/SnapshotJournalReceipt.ts', + 'src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts', + 'src/storage/snapshot/journal/SnapshotJournalConnections.ts', + 'src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', + 'src/storage/snapshot/journal/SnapshotJournalCapture.ts' ], ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/journal/*.test.ts'], { maxTestRunnerReuse: 8, diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index e77d77095..e64663fd4 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries." + "summary": "Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 2c2d16379..b81d764f2 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,11 +6,23 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add internal owned journal capture for `better-sqlite3` WAL and static `mysql2`. + Reserve both pools before the short writer barrier; pin generation/profile, + commit the exact receipt and verify closure before publication. Retain source + admission through physical cleanup and fence an unproved close. Native fixtures + cover commit/publication process loss, immutable pages and writer progress. + Continuity floors, quotas, delta receiver integration and full #544 acceptance + remain unfinished; no migration or incremental capability is advertised. + +- Preserve main's Postgres storage and legacy sync support. SQLite/MySQL snapshot + auxiliary migrations are recorded as no-ops there, with their capabilities + unavailable. Future Postgres snapshot support requires new forward migrations. + - Add internal, unadvertised SQL journal foundation: exact revisions, bounded pages/bootstrap, full-table observers and source-bound generation ownership with interrupted-installation recovery. No public capability, registered migration, deployment or completed incremental sync is introduced. Full - quota/receipt/receiver/primary and portability acceptance remains pending. + quota/receiver/primary and portability acceptance remains pending. - Repair SQLite replacement maintenance with a new auxiliary generation, durable displaced-owner witnesses, bounded resumable source copying and physical-row diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 8d3b781bc..8b2665b2e 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -63,11 +63,17 @@ source-table observers, and recovery of an interrupted journal installation. Owned generations also bind explicit receipt capacity/lifetime policy; exact receipts are persisted, read and collected in bounded caller-owned transactions. Receipts prove a captured prefix and cannot -reopen a lost retained view. The capture controller remains unfinished. Run `pnpm test:snapshot-journal-crash` for the +reopen a lost retained view. The internal `openSnapshotJournalSource` controller +reserves two owned native connections before its writer barrier, pins the profile +and generation, persists the receipt, and publishes after receipt commit and +closure verification. It requires an existing file-backed WAL database with +`better-sqlite3` or a static `mysql2` connection; other drivers refuse before pool +construction. Physical cleanup retains provider admission, and an unproved close +fences further sources. Run `pnpm test:snapshot-journal-crash` for the SQLite process-loss fixture and `pnpm test:snapshot-journal-mysql` for the isolated MySQL client/server-process recovery fixtures. These helpers do not register a migration or advertise incremental synchronization. Full retention quotas, -capture publication, continuity floors, receiver/primary integration and the remaining issue #544 +continuity floors, generation-aware delta pages and receiver/primary integration and the remaining issue #544 acceptance work are still required; see the [journal foundation](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#internal-journal-foundation-unadvertised). diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index badefe2e0..1dae5a59c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,3 +1,10 @@ +import { + retainSnapshotJournalCapture, + type SnapshotJournalCaptureRequest, + type SnapshotJournalCaptureLifetime, + type SnapshotJournalSource +} from './snapshot/journal/SnapshotJournalCapture' +import { SnapshotJournalConnectionCleanupError } from './snapshot/journal/SnapshotJournalConnections' import { dropGenerationForDataDeletion } from './schema/snapshotSqliteIndexMigration' import { migration as SNAPSHOT_SQLITE_INDEX_MIGRATION } from './schema/snapshotSqliteIndexState' import { SnapshotResourceLimitError } from './snapshot/SnapshotResourceLimitError' @@ -140,6 +147,8 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide private snapshotSyncSource?: StorageKnex private snapshotSyncOpening?: Promise private snapshotSyncBusy = false + private snapshotJournalCapture?: SnapshotJournalCaptureLifetime + private snapshotJournalCaptureFailure?: SnapshotJournalConnectionCleanupError private snapshotArchiveRecovery?: Promise private guardedSnapshotFailure?: { error: unknown } private retainedReadSnapshot?: RetainedReadSnapshotLifetime @@ -274,9 +283,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide try { await this.snapshotArchiveRecovery } finally { - await this.snapshotSyncOpening?.catch(() => undefined) - await this.snapshotSyncSource?.destroy() - await this.retainedReadSnapshot?.close() + try { + await this.snapshotJournalCapture?.close() + } finally { + await this.snapshotSyncOpening?.catch(() => undefined) + await this.snapshotSyncSource?.destroy() + await this.retainedReadSnapshot?.close() + } } } @@ -377,6 +390,58 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } } + /** Internal complete-generation capture; no RPC or incremental capability is advertised. + * The source shares sync/archive admission and retains it through physical cleanup. + */ + async openSnapshotJournalSource( + identityKey: string, + request: SnapshotJournalCaptureRequest, + options: RetainedReadSnapshotOptions = {} + ): Promise { + if (this.retainedReadSnapshotsStopped) + throw new WERR_INVALID_OPERATION('Snapshot journal sources are unavailable after destruction begins') + if (this.snapshotSyncBusy) + throw new WERR_INVALID_OPERATION('This provider already has a snapshot sync source opening or active') + this.snapshotSyncBusy = true + let lifetime: SnapshotJournalCaptureLifetime + try { + lifetime = retainSnapshotJournalCapture( + this.chain, + async () => { + if (this.retainedReadSnapshotsStopped) + throw new WERR_INVALID_OPERATION('Snapshot journal sources are unavailable after destruction begins') + return await this.concurrentSnapshotReaderConfig() + }, + identityKey, + request, + options + ) + } catch (error) { + this.snapshotSyncBusy = false + throw error + } + this.snapshotJournalCapture = lifetime + const release = (error?: unknown): void => { + if (error instanceof SnapshotJournalConnectionCleanupError) { + this.snapshotJournalCaptureFailure = error + this.retainedReadSnapshotsStopped = true + return + } + if (this.snapshotJournalCapture === lifetime) { + this.snapshotJournalCapture = undefined + this.snapshotSyncBusy = false + } + } + void lifetime.closed.then(() => release(), release) + return await lifetime.opened + } + + /** Drain an already-stopping capture without admitting another source. */ + awaitSnapshotJournalCaptureCleanup(): Promise { + if (this.snapshotJournalCaptureFailure !== undefined) return Promise.reject(this.snapshotJournalCaptureFailure) + return this.snapshotJournalCapture?.closed ?? Promise.resolve() + } + /** One bounded recovery flight per provider, drained by provider destruction. */ recoverSnapshotArchiveSources(): Promise { if (this.retainedReadSnapshotsStopped) @@ -413,7 +478,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs < 1 || lifetimeMs > 3600000) { throw new WERR_INVALID_PARAMETER('lifetimeMs', 'an integer from 1 to 3600000') } - const deadline = { expiresAt: Date.now() + lifetimeMs, startedAt: performance.now(), lifetimeMs } + const deadline = { + expiresAt: Date.now() + lifetimeMs, + startedAt: performance.now(), + lifetimeMs + } this.snapshotSyncBusy = true const opening: Promise = this.createConcurrentSyncSource( identityKey, @@ -2045,7 +2114,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } catch (error) { // Preserve an ordinary read failure for its read consumer. A typed // cleanup failure remains observable after the pool destruction below. - if (this.guardedSnapshotFailure === undefined || this.guardedSnapshotFailure.error !== error) throw error + if ( + (this.guardedSnapshotFailure === undefined || this.guardedSnapshotFailure.error !== error) && + this.snapshotJournalCaptureFailure !== error + ) + throw error } finally { await this.stopPreparedBeefTasks() this.knex.off('query', this.onQuery) @@ -2059,6 +2132,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide // A settled pool alone cannot prove that a failed native close succeeded. const failure = this.guardedSnapshotFailure?.error if (failure instanceof SnapshotArchiveSourceCleanupError) throw failure + if (this.snapshotJournalCaptureFailure !== undefined) throw this.snapshotJournalCaptureFailure } override async migrate(storageName: string, storageIdentityKey: string): Promise { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts index 065e71183..f07f5ab0c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.ts @@ -52,7 +52,7 @@ export interface RetainedReadSnapshotLifetime { } export function retainReadSnapshot( - run: (read: (trx: TrxToken) => Promise) => Promise, + run: (read: (trx: TrxToken) => Promise, assertActive: () => void) => Promise, establishView: (trx: TrxToken) => Promise, options: RetainedReadSnapshotOptions = {} ): RetainedReadSnapshotLifetime { @@ -149,7 +149,7 @@ export function retainReadSnapshot( await stopped.promise await inFlight if (readFailure !== undefined) throw readFailure.error - }) + }, assertOpen) } closed.resolve() } catch (error) { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts index 5351a4487..3c11531d6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveSource.ts @@ -39,6 +39,71 @@ export async function readSnapshotArchiveSourceSchema(storage: StorageKnex, k: K return row.name } +/** Read source metadata and index ownership in the caller's already pinned view. */ +export async function readKnexSnapshotArchiveHeader(storage: StorageKnex, identityKey: string, k: Knex) { + const generation = await readGenerationIndexState(k, storage.knex.client.config.migrations) + const sourceStorage = await storage.readSettings(k) + const user = await storage.findUserByIdentityKey(identityKey, k) + if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') + return { + header: { + sourceStorage, + user, + sourceSchema: await readSnapshotArchiveSourceSchema(storage, k) + }, + profileIndexes: generation ?? (await readSnapshotProfileIndexState(k, storage.knex.client.config.migrations)), + relationIndexes: generation ?? (await readSnapshotRelationIndexState(k, storage.knex.client.config.migrations)), + globalIndexes: generation ?? (await readSnapshotGlobalIndexState(k, storage.knex.client.config.migrations)), + certificateIndexes: + generation ?? (await readSnapshotCertificateIndexState(k, storage.knex.client.config.migrations)) + } +} + +export type KnexSnapshotArchiveHeader = Awaited> + +/** Construct a handle only after its caller has proved capture publication. */ +export function createKnexSnapshotArchiveSource( + storage: StorageKnex, + view: RetainedReadSnapshot, + { header, profileIndexes, relationIndexes, certificateIndexes, globalIndexes }: KnexSnapshotArchiveHeader +): SnapshotArchiveSource { + const userId = header.user.userId + const snapshotId = Utils.toHex(Random(32)) + return { + version: 1, + snapshotId, + ...header, + expiresAt: view.expiresAt, + get isOpen() { + return view.isOpen + }, + closed: view.closed, + close: view.close, + readPage: createKnexWalletSnapshotPageReader( + storage, + userId, + snapshotId, + view, + profileIndexes, + relationIndexes, + certificateIndexes, + globalIndexes + ), + validateClosure: async () => { + await view.read(trx => + assertKnexSnapshotArchiveClosure( + storage.toDb(trx), + userId, + profileIndexes, + relationIndexes, + certificateIndexes, + globalIndexes + ) + ) + } + } +} + /** * Internal SQL capture source. The caller supplies a dedicated reader provider, * separate from the staging writer, and awaits close before releasing it. @@ -55,68 +120,8 @@ export async function openKnexSnapshotArchiveSource( } const view = await openView() try { - const { header, profileIndexes, relationIndexes, certificateIndexes, globalIndexes } = await view.read( - async trx => { - const generation = await readGenerationIndexState(storage.toDb(trx), storage.knex.client.config.migrations) - const sourceStorage = await storage.readSettings(trx) - const user = await storage.findUserByIdentityKey(identityKey, trx) - if (user === undefined) throw new WERR_INVALID_PARAMETER('identityKey', 'an existing wallet profile') - return { - header: { - sourceStorage, - user, - sourceSchema: await readSnapshotArchiveSourceSchema(storage, storage.toDb(trx)) - }, - profileIndexes: - generation ?? - (await readSnapshotProfileIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), - relationIndexes: - generation ?? - (await readSnapshotRelationIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), - globalIndexes: - generation ?? - (await readSnapshotGlobalIndexState(storage.toDb(trx), storage.knex.client.config.migrations)), - certificateIndexes: - generation ?? - (await readSnapshotCertificateIndexState(storage.toDb(trx), storage.knex.client.config.migrations)) - } - } - ) - const userId = header.user.userId - const snapshotId = Utils.toHex(Random(32)) - return { - version: 1, - snapshotId, - ...header, - expiresAt: view.expiresAt, - get isOpen() { - return view.isOpen - }, - closed: view.closed, - close: view.close, - readPage: createKnexWalletSnapshotPageReader( - storage, - userId, - snapshotId, - view, - profileIndexes, - relationIndexes, - certificateIndexes, - globalIndexes - ), - validateClosure: async () => { - await view.read(trx => - assertKnexSnapshotArchiveClosure( - storage.toDb(trx), - userId, - profileIndexes, - relationIndexes, - certificateIndexes, - globalIndexes - ) - ) - } - } + const header = await view.read(trx => readKnexSnapshotArchiveHeader(storage, identityKey, storage.toDb(trx))) + return createKnexSnapshotArchiveSource(storage, view, header) } catch (error) { await view.close().catch(() => undefined) throw error diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts new file mode 100644 index 000000000..31732171b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts @@ -0,0 +1,414 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' +import { + installSnapshotJournalSqliteGeneration, + completeSnapshotJournalSqliteGeneration +} from './SnapshotJournalSqliteGeneration' +import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' +import { readSnapshotJournalReceipt } from './SnapshotJournalReceipt' +import { snapshotJournalRevision } from './SnapshotJournalRevision' +import { SnapshotJournalConnectionCleanupError } from './SnapshotJournalConnections' +import type { SnapshotJournalSource } from './SnapshotJournalCapture' +import * as Closure from '../archive/KnexSnapshotArchiveClosure' +import * as Backend from './SnapshotJournalCaptureBackend' +import * as Receipts from './SnapshotJournalReceipt' +import { snapshotArchiveTables } from '../archive/KnexSnapshotArchiveStore' + +const identity = '02' + '11'.repeat(32) +const request = { + ceiling: snapshotJournalRevision('1000000'), + receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } +} +function gate() { + let resolve!: () => void + const promise = new Promise(yes => { + resolve = yes + }) + return { promise, resolve } +} +async function fixture(complete = true) { + const directory = await mkdtemp(join(tmpdir(), 'ts569-capture-controller-')) + const k = knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + try { + await storage.migrate('capture controller', 'synthetic-capture-controller') + await storage.makeAvailable() + await k.raw('PRAGMA journal_mode = WAL') + const { user } = await storage.findOrInsertUser(identity) + const { user: foreign } = await storage.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(storage, user.userId, foreign.userId) + await installSnapshotJournalSqliteGeneration(k, request.ceiling, request.receiptPolicy) + if (complete) { + let finished = false + for (let n = 0; n < 80 && !finished; n++) finished = (await copySnapshotJournalBootstrapPage(k, 1000000)).complete + expect(finished).toBe(true) + await completeSnapshotJournalSqliteGeneration(k, request.receiptPolicy) + } + return { + k, + storage, + directory, + close: async () => { + await storage.destroy() + await rm(directory, { recursive: true, force: true }) + } + } + } catch (error) { + await storage.destroy() + await rm(directory, { recursive: true, force: true }) + throw error + } +} + +afterEach(() => jest.restoreAllMocks()) + +test('all thirteen tables retain their pinned values while independent writers modify every table', async () => { + const f = await fixture() + const view = await f.storage.openSnapshotJournalSource(identity, request) + const raw = [ + 'proven_txs', + 'proven_tx_reqs', + 'output_baskets', + 'transactions', + 'commissions', + 'outputs', + 'output_tags', + 'output_tags_map', + 'tx_labels', + 'tx_labels_map', + 'certificates', + 'certificate_fields', + 'sync_states' + ] + try { + expect(snapshotArchiveTables).toHaveLength(13) + const before = [] + for (const table of snapshotArchiveTables) { + const page = await view.readPage(table) + expect(page.done).toBe(true) + expect(page.rows.length).toBeGreaterThan(0) + before.push(page.rows) + } + for (const table of raw) await f.k(table).update({ created_at: new Date('2026-01-02T00:00:00Z').getTime() }) + for (const [index, table] of snapshotArchiveTables.entries()) { + const page = await view.readPage(table) + expect(page.rows).toEqual(before[index]) + for (const row of page.rows) if ('userId' in row) expect(row.userId).toBe(view.user.userId) + } + expect(before[0].map(row => ('provenTxId' in row ? row.provenTxId : undefined))).toEqual([1, 3]) + expect(before[1].map(row => ('provenTxReqId' in row ? row.provenTxReqId : undefined))).toEqual([1, 3]) + expect(before[12].map(row => ('syncStateId' in row ? row.syncStateId : undefined))).toEqual([1, 3]) + } finally { + await view.close() + await f.close() + } +}) + +test('profile closure verification holds no writer barrier and publishes only after verification finishes', async () => { + const f = await fixture(), + entered = gate(), + release = gate() + const verify = Closure.assertKnexSnapshotArchiveClosure + jest.spyOn(Closure, 'assertKnexSnapshotArchiveClosure').mockImplementation(async (...args) => { + entered.resolve() + await release.promise + await verify(...args) + }) + let view: SnapshotJournalSource | undefined + try { + const opening = f.storage.openSnapshotJournalSource(identity, request) + let published = false + void opening.then( + () => { + published = true + }, + () => undefined + ) + await entered.promise + expect(await f.k('snapshot_journal_receipts')).toHaveLength(1) + await f.k('tx_labels').where('txLabelId', 1).update({ label: 'foreground during closure' }) + expect(published).toBe(false) + release.resolve() + view = await opening + expect((await view.readPage('txLabels')).rows.some(row => row.label === 'foreground during closure')).toBe(false) + } finally { + release.resolve() + await view?.close() + await f.close() + } +}) + +test.each(['receipt before commit', 'closure after commit'] as const)( + 'cancellation during %s rejects promptly and drains before source capacity can reopen', + async phase => { + const f = await fixture(), + entered = gate(), + release = gate(), + controller = new AbortController() + if (phase === 'receipt before commit') { + const record = Receipts.recordSnapshotJournalReceipt + jest.spyOn(Receipts, 'recordSnapshotJournalReceipt').mockImplementation(async (...args) => { + const result = await record(...args) + entered.resolve() + await release.promise + return result + }) + } else { + const verify = Closure.assertKnexSnapshotArchiveClosure + jest.spyOn(Closure, 'assertKnexSnapshotArchiveClosure').mockImplementation(async (...args) => { + entered.resolve() + await release.promise + await verify(...args) + }) + } + try { + const opening = f.storage.openSnapshotJournalSource(identity, request, { + signal: controller.signal + }) + const rejection = expect(opening).rejects.toThrow('cancelled') + await entered.promise + controller.abort() + await rejection + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('already has') + let drained = false + const cleanup = f.storage.awaitSnapshotJournalCaptureCleanup().then(() => { + drained = true + }) + await Promise.resolve() + expect(drained).toBe(false) + release.resolve() + await cleanup + expect(await f.k('snapshot_journal_receipts')).toHaveLength(phase === 'closure after commit' ? 1 : 0) + jest.restoreAllMocks() + const next = await f.storage.openSnapshotJournalSource(identity, request) + expect(await f.k('snapshot_journal_receipts')).toHaveLength(phase === 'closure after commit' ? 2 : 1) + await next.close() + } finally { + release.resolve() + jest.restoreAllMocks() + await f.close() + } + } +) + +test('provider publishes only a committed receipt, serves immutable profile pages and releases its single source admission', async () => { + const f = await fixture() + let view: SnapshotJournalSource | undefined + try { + const labels = await f.storage.findTxLabels({ partial: { userId: 1 } }) + view = await f.storage.openSnapshotJournalSource(identity, request) + expect(view.sourceStorage.chain).toBe('test') + expect(view.user.identityKey).toBe(identity) + expect(await f.k.transaction(t => readSnapshotJournalReceipt(t, view!.receiptBinding, view!.receipt))).toEqual( + view.receipt + ) + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('already has') + await expect(f.storage.openSnapshotArchiveSource(identity)).rejects.toThrow('already has') + await f.k('tx_labels').where('txLabelId', 1).update({ label: 'after capture' }) + const page = await view.readPage('txLabels') + expect(page.rows.map(row => row.label)).toEqual(labels.map(row => row.label)) + expect(page.rows.every(row => row.userId === view!.user.userId)).toBe(true) + const rows: Array<{ revision: string }> = await f + .k('snapshot_journal_physical') + .select(f.k.raw('CAST(revision AS TEXT) revision')) + expect(rows.some(row => BigInt(row.revision) > BigInt(view!.receipt.highWater))).toBe(true) + await view.close() + const second = await f.storage.openSnapshotJournalSource(identity, request) + expect(second.receipt.requestId).not.toBe(view.receipt.requestId) + expect(BigInt(second.receipt.highWater)).toBeGreaterThan(BigInt(view.receipt.highWater)) + expect((await second.readPage('txLabels')).rows.some(row => row.label === 'after capture')).toBe(true) + await second.close() + } finally { + await view?.close() + await f.close() + } +}) + +test('unfinished generation refuses publication and leaves no receipt; completed migration can be retried', async () => { + const f = await fixture(false) + try { + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('generation') + expect(await f.k('snapshot_journal_receipts')).toEqual([]) + let finished = false + for (let n = 0; n < 80 && !finished; n++) finished = (await copySnapshotJournalBootstrapPage(f.k, 1000000)).complete + await completeSnapshotJournalSqliteGeneration(f.k, request.receiptPolicy) + const view = await f.storage.openSnapshotJournalSource(identity, request) + await view.close() + } finally { + await f.close() + } +}) + +test('an occupied writer refuses promptly without publishing or replaying the independent writer', async () => { + const f = await fixture() + const other = knex(f.k.client.config) + let writer: Knex.Transaction | undefined + try { + writer = await other.transaction() + await writer('tx_labels').where('txLabelId', 1).update({ label: 'uncommitted writer' }) + const start = performance.now() + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toMatchObject({ + code: 'SQLITE_BUSY' + }) + expect(performance.now() - start).toBeLessThan(1000) + expect(await f.k('snapshot_journal_receipts')).toEqual([]) + await writer.commit() + writer = undefined + const view = await f.storage.openSnapshotJournalSource(identity, request) + expect((await view.readPage('txLabels')).rows.some(row => row.label === 'uncommitted writer')).toBe(true) + await view.close() + } finally { + await writer?.rollback() + await other.destroy() + await f.close() + } +}) + +test('cancelled pool setup rejects opening promptly and retains admission until the pending work drains', async () => { + const f = await fixture() + const entered = gate(), + release = gate(), + controller = new AbortController() + const acquire = f.k.client.acquireConnection.bind(f.k.client) + jest.spyOn(f.k.client, 'acquireConnection').mockImplementation(async () => { + entered.resolve() + await release.promise + return await acquire() + }) + try { + const opening = f.storage.openSnapshotJournalSource(identity, request, { + signal: controller.signal + }) + const rejection = expect(opening).rejects.toThrow('cancelled') + await entered.promise + controller.abort() + await rejection + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('already has') + let destroyed = false + const destruction = f.storage.destroy().then(() => { + destroyed = true + }) + await Promise.resolve() + expect(destroyed).toBe(false) + release.resolve() + await destruction + expect(destroyed).toBe(true) + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('destruction') + } finally { + release.resolve() + jest.restoreAllMocks() + await f.close() + } +}) + +test('provider destruction drains an active captured view before closing the foreground pool', async () => { + const f = await fixture() + try { + const view = await f.storage.openSnapshotJournalSource(identity, request) + await f.storage.destroy() + expect(view.isOpen).toBe(false) + await view.closed + await expect(view.readPage('txLabels')).rejects.toThrow('closed') + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('destruction') + } finally { + await f.close() + } +}) + +test('public input is detached before asynchronous setup and invalid input does not reserve admission', async () => { + const f = await fixture() + try { + await expect(f.storage.openSnapshotJournalSource('foreign', request)).rejects.toThrow('identityKey') + const input = { ...request, receiptPolicy: { ...request.receiptPolicy } } + const opening = f.storage.openSnapshotJournalSource(identity, input) + input.receiptPolicy.receiptLimit = 1 + input.ceiling = snapshotJournalRevision('1') + const view = await opening + expect(view.receipt.highWater).not.toBe('1') + await view.close() + } finally { + await f.close() + } +}) + +test('source failure is retriable after physical cleanup; an unproved close fences provider capacity', async () => { + const f = await fixture() + try { + await expect(f.storage.openSnapshotJournalSource('02' + '99'.repeat(32), request)).rejects.toThrow( + 'existing wallet profile' + ) + const view = await f.storage.openSnapshotJournalSource(identity, request) + await view.close() + // The cleanup helper is exercised with a failed native close in the backend + // tests. Here assert provider handling of that exact ownership-failure type. + const fault = new SnapshotJournalConnectionCleanupError(new Error('native close failed')) + const config = jest.spyOn(f.k.client, 'acquireConnection').mockRejectedValue(fault) + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toBe(fault) + config.mockRestore() + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('destruction') + await expect(f.storage.destroy()).rejects.toBe(fault) + } finally { + jest.restoreAllMocks() + await f.storage.destroy().catch(() => undefined) + await rm(f.directory, { recursive: true, force: true }) + } +}) + +test('the pinned database type is validated without changing foreground cached settings', async () => { + const f = await fixture() + try { + const cached = f.storage.getSettings() + await f.k('settings').update({ dbtype: 'MySQL' }) + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('generation or profile') + expect(f.storage.getSettings()).toBe(cached) + expect(f.storage.getSettings().dbtype).toBe('SQLite') + expect(await f.k('snapshot_journal_receipts')).toHaveLength(0) + await f.k('settings').update({ dbtype: 'SQLite' }) + const next = await f.storage.openSnapshotJournalSource(identity, request) + expect(next.sourceStorage.dbtype).toBe('SQLite') + await next.close() + } finally { + await f.close() + } +}) + +test('a source failure and owned-provider cleanup failure remain observable together', async () => { + const f = await fixture() + const sourceError = new Error('Backend identity changed') + const cleanupError = new Error('Owned provider did not close') + const destroy = StorageKnex.prototype.destroy + jest.spyOn(Backend, 'bindSnapshotJournalCaptureBackend').mockRejectedValue(sourceError) + jest.spyOn(StorageKnex.prototype, 'destroy').mockImplementation(async function (this: StorageKnex) { + await destroy.call(this) + if (this !== f.storage) throw cleanupError + }) + function causes(value: unknown): unknown[] { + return [ + value, + ...(value instanceof AggregateError ? value.errors.flatMap(causes) : []), + ...(value instanceof Error && value.cause !== undefined ? causes(value.cause) : []) + ] + } + try { + const error = await f.storage.openSnapshotJournalSource(identity, request).catch(value => value) + expect(error).toBeInstanceOf(SnapshotJournalConnectionCleanupError) + expect(causes(error)).toEqual(expect.arrayContaining([sourceError, cleanupError])) + await expect(f.storage.awaitSnapshotJournalCaptureCleanup()).rejects.toBe(error) + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('destruction') + await expect(f.storage.destroy()).rejects.toBe(error) + } finally { + jest.restoreAllMocks() + await f.storage.destroy().catch(() => undefined) + await rm(f.directory, { recursive: true, force: true }) + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts new file mode 100644 index 000000000..86739f06e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts @@ -0,0 +1,289 @@ +import { createHash, randomBytes } from 'node:crypto' +import { knex as createKnex, type Knex } from 'knex' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import type { Chain } from '../../../sdk/types' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER, WERR_NOT_IMPLEMENTED } from '../../../sdk/WERR_errors' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import { retainReadSnapshot, type RetainedReadSnapshotOptions } from '../RetainedReadSnapshot' +import { + readKnexSnapshotArchiveHeader, + createKnexSnapshotArchiveSource, + type KnexSnapshotArchiveHeader, + type SnapshotArchiveSource +} from '../archive/KnexSnapshotArchiveSource' +import { assertKnexSnapshotArchiveClosure } from '../archive/KnexSnapshotArchiveClosure' +import { snapshotArchiveDatabaseNow } from '../archive/SnapshotArchiveSql' +import { + snapshotJournalReceiptPolicy, + recordSnapshotJournalReceipt, + type SnapshotJournalReceiptPolicy, + type SnapshotJournalReceipt, + type SnapshotJournalReceiptBinding +} from './SnapshotJournalReceipt' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' +import { readSnapshotJournalSqliteGeneration } from './SnapshotJournalSqliteGeneration' +import { readSnapshotJournalMysqlGeneration } from './SnapshotJournalMysqlGeneration' +import { reserveSnapshotJournalCaptureFence } from './SnapshotJournalCaptureFence' +import { withSnapshotJournalConnections, SnapshotJournalConnectionCleanupError } from './SnapshotJournalConnections' +import { + prepareSnapshotJournalCaptureBackend, + bindSnapshotJournalCaptureBackend, + closeSnapshotJournalCapturePool +} from './SnapshotJournalCaptureBackend' + +export interface SnapshotJournalCaptureRequest { + ceiling: SnapshotJournalRevision + receiptPolicy: SnapshotJournalReceiptPolicy +} +export interface SnapshotJournalSource extends SnapshotArchiveSource { + readonly receipt: SnapshotJournalReceipt + readonly receiptBinding: SnapshotJournalReceiptBinding +} +export interface SnapshotJournalCaptureLifetime { + opened: Promise + closed: Promise + close: () => Promise +} +interface Captured { + header: KnexSnapshotArchiveHeader + receipt: SnapshotJournalReceipt + binding: SnapshotJournalReceiptBinding +} + +function invalid(): never { + throw new WERR_INVALID_OPERATION('Snapshot journal capture generation or profile is unavailable') +} +function ownedConfig(config: Knex.Config): Knex.Config { + const connection = config.connection as object + return { + ...config, + connection: Object.create(Object.getPrototypeOf(connection), Object.getOwnPropertyDescriptors(connection)), + pool: { ...config.pool, min: 0, max: 1 }, + acquireConnectionTimeout: Math.min(config.acquireConnectionTimeout ?? 5000, 5000) + } +} +async function endTransactions(transactions: Knex.Transaction[]): Promise { + const settled = await Promise.allSettled( + transactions.map(async trx => { + if (!trx.isCompleted()) { + await trx.rollback() + await trx.executionPromise + } + }) + ) + const failed = settled.filter(result => result.status === 'rejected') + if (failed.length) + throw new SnapshotJournalConnectionCleanupError( + new AggregateError( + failed.map(result => result.reason), + 'Snapshot capture transactions did not drain' + ) + ) +} +async function begin(k: Knex, connection: unknown, transactions: Knex.Transaction[]): Promise { + const trx = await k.transaction({ connection }) + void trx.executionPromise.catch(() => undefined) + transactions.push(trx) + return trx +} +async function commit(trx: Knex.Transaction): Promise { + // Knex's commit query can resolve after a SQL error while executionPromise + // rejects. Both must settle successfully before a receipt authorizes a view. + await trx.commit() + await trx.executionPromise +} +async function prepareReader(writer: Knex, write: unknown, reader: Knex, read: unknown): Promise { + if (reader.client.config.client === 'better-sqlite3') { + await writer.raw('PRAGMA busy_timeout = 0').connection(write) + await reader.raw('PRAGMA query_only = ON').connection(read) + } else { + await reader.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY').connection(read) + } +} +async function capture( + storage: StorageKnex, + writer: Knex, + write: unknown, + read: unknown, + backend: string, + identityKey: string, + request: SnapshotJournalCaptureRequest, + assertActive: () => void, + hold: (trx: Knex.Transaction, captured: Captured) => Promise +): Promise { + const reader = storage.knex, + transactions: Knex.Transaction[] = [] + try { + await prepareReader(writer, write, reader, read) + assertActive() + const barrier = await begin(writer, write, transactions) + assertActive() + const highWater = await reserveSnapshotJournalCaptureFence(barrier) + if (highWater === undefined) { + await commit(barrier) + throw new SnapshotResourceLimitError('Snapshot journal event window is exhausted or disabled') + } + assertActive() + const view = await begin(reader, read, transactions) + assertActive() + const generation = + reader.client.config.client === 'better-sqlite3' + ? await readSnapshotJournalSqliteGeneration(view, request.receiptPolicy, reader.client.config.migrations) + : await readSnapshotJournalMysqlGeneration( + view, + request.ceiling, + request.receiptPolicy, + reader.client.config.migrations + ) + if (!generation.complete || !generation.enabled || generation.ceiling !== request.ceiling) return invalid() + assertActive() + const header = await readKnexSnapshotArchiveHeader(storage, identityKey, view) + if ( + header.header.user.identityKey.toLowerCase() !== identityKey || + header.header.sourceStorage.chain !== storage.chain || + header.header.sourceStorage.dbtype !== (reader.client.config.client === 'better-sqlite3' ? 'SQLite' : 'MySQL') + ) + return invalid() + // This private provider belongs only to this pinned read view. Page size + // preflight needs its database type; never initialize it from a live query + // or change the foreground provider's cached settings. + storage._settings = { ...header.header.sourceStorage } + const binding: SnapshotJournalReceiptBinding = { + backend, + epoch: generation.epoch, + source: generation.source, + schema: createHash('sha256') + .update('snapshot-journal-schema-v1\n') + .update(header.header.sourceSchema) + .digest('hex'), + storageIdentity: header.header.sourceStorage.storageIdentityKey, + identityKey, + userId: header.header.user.userId, + chain: storage.chain + } + assertActive() + const receipt = await recordSnapshotJournalReceipt(barrier, binding, { + requestId: randomBytes(32).toString('hex'), + highWater, + expiresAt: (await snapshotArchiveDatabaseNow(barrier)) + request.receiptPolicy.receiptLifetimeMs + }) + assertActive() + await commit(barrier) + assertActive() + await hold(view, { header, receipt, binding }) + } finally { + await endTransactions(transactions) + } +} + +/** Internal owned capture. The provider keeps its admission until closed settles. + * This does not register a journal migration or advertise incremental sync. */ +export function retainSnapshotJournalCapture( + chain: Chain, + resolveConfig: () => Promise, + identity: string, + input: SnapshotJournalCaptureRequest, + options: RetainedReadSnapshotOptions = {} +): SnapshotJournalCaptureLifetime { + if (typeof identity !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(identity)) + throw new WERR_INVALID_PARAMETER('identityKey', 'a compressed public identity key') + const identityKey = identity.toLowerCase() + const request = { + ceiling: snapshotJournalRevision(input.ceiling), + receiptPolicy: snapshotJournalReceiptPolicy(input.receiptPolicy) + } + if (request.ceiling === '0') return invalid() + let storage: StorageKnex | undefined, captured: Captured | undefined + const lifetime = retainReadSnapshot( + async (hold, assertActive) => { + let writer: Knex | undefined, failure: { error: unknown } | undefined + try { + const config = await resolveConfig() + assertActive() + if (config === undefined) + throw new WERR_NOT_IMPLEMENTED('Snapshot journal capture requires file-backed SQLite WAL or static MySQL') + const expected = await prepareSnapshotJournalCaptureBackend(config) + assertActive() + writer = createKnex(ownedConfig(config)) + storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions(chain), + snapshotSync: false, + knex: createKnex(ownedConfig(config)) + }) + await withSnapshotJournalConnections( + writer, + storage.knex, + assertActive, + async (write, read) => { + const backend = await bindSnapshotJournalCaptureBackend(writer!, write, storage!.knex, read, expected) + assertActive() + await capture( + storage!, + writer!, + write, + read, + backend, + identityKey, + request, + assertActive, + async (trx, value) => { + captured = value + await hold(trx) + } + ) + }, + closeSnapshotJournalCapturePool + ) + } catch (error) { + // Cancellation rejects opening promptly, but physical cleanup still + // controls closed/admission. Preserve unrelated source failures too. + let cancelled = false + try { + assertActive() + } catch (stop) { + cancelled = stop === error + } + if (!cancelled) failure = { error } + } + const settled = await Promise.allSettled([writer?.destroy(), storage?.destroy()]) + const failed = settled.filter(result => result.status === 'rejected') + if (failed.length) + throw new SnapshotJournalConnectionCleanupError( + new AggregateError( + [...(failure === undefined ? [] : [failure.error]), ...failed.map(result => result.reason)], + 'Snapshot capture owned providers did not close' + ) + ) + if (failure !== undefined) throw failure.error + }, + async trx => { + if (storage === undefined || captured === undefined) return invalid() + const h = captured.header + // Closure verification uses the pinned reader after the writer barrier has + // committed, so foreground writers can progress throughout this work. + await assertKnexSnapshotArchiveClosure( + storage.toDb(trx), + h.header.user.userId, + h.profileIndexes, + h.relationIndexes, + h.certificateIndexes, + h.globalIndexes + ) + }, + { ...options } + ) + const opened = lifetime.opened.then(view => { + if (storage === undefined || captured === undefined || !view.isOpen) return invalid() + return { + ...createKnexSnapshotArchiveSource(storage, view, captured.header), + get isOpen() { + return view.isOpen + }, + receipt: { ...captured.receipt }, + receiptBinding: { ...captured.binding } + } + }) + void opened.catch(() => undefined) + return { opened, closed: lifetime.closed, close: lifetime.close } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.test.ts new file mode 100644 index 000000000..45a7a25cf --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.test.ts @@ -0,0 +1,309 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Duplex } from 'node:stream' +import { + prepareSnapshotJournalCaptureBackend, + bindSnapshotJournalCaptureBackend, + closeSnapshotJournalCapturePool +} from './SnapshotJournalCaptureBackend' +import { withSnapshotJournalConnections, SnapshotJournalConnectionCleanupError } from './SnapshotJournalConnections' + +function gate() { + let resolve!: () => void + const promise = new Promise(yes => { + resolve = yes + }) + return { promise, resolve } +} +function sqlite(filename: string) { + return knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) +} +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'ts569-capture-backend-')) + const filename = join(directory, 'wallet.sqlite') + const writer = sqlite(filename), + reader = sqlite(filename) + await writer.raw('PRAGMA journal_mode = WAL') + return { + directory, + filename, + writer, + reader, + close: async () => { + await Promise.allSettled([writer.destroy(), reader.destroy()]) + await rm(directory, { recursive: true, force: true }) + } + } +} + +test('fresh independent SQLite pools bind one existing file and physically close both reserved native connections', async () => { + const f = await fixture() + try { + const expected = await prepareSnapshotJournalCaptureBackend(f.writer.client.config) + const connections: Array<{ open: boolean }> = [] + const digest = await withSnapshotJournalConnections( + f.writer, + f.reader, + () => undefined, + async (write, read) => { + connections.push(write as { open: boolean }, read as { open: boolean }) + return await bindSnapshotJournalCaptureBackend(f.writer, write, f.reader, read, expected) + }, + closeSnapshotJournalCapturePool + ) + expect(digest).toMatch(/^[0-9a-f]{64}$/) + expect(connections).toHaveLength(2) + expect(connections.map(value => value.open)).toEqual([false, false]) + expect(f.writer.client.pool).toBeUndefined() + expect(f.reader.client.pool).toBeUndefined() + } finally { + await f.close() + } +}) + +test('a replaced SQLite pathname after preparation refuses the source before its barrier callback', async () => { + const f = await fixture() + try { + const expected = await prepareSnapshotJournalCaptureBackend(f.writer.client.config) + await f.writer.destroy() + await rename(f.filename, join(f.directory, 'original.sqlite')) + await writeFile(f.filename, '') + const writer = sqlite(f.filename) + const barrier = jest.fn(async () => undefined) + await expect( + withSnapshotJournalConnections( + writer, + f.reader, + () => undefined, + async (write, read) => { + await bindSnapshotJournalCaptureBackend(writer, write, f.reader, read, expected) + await barrier() + }, + closeSnapshotJournalCapturePool + ) + ).rejects.toThrow('changed') + expect(barrier).not.toHaveBeenCalled() + } finally { + await f.close() + } +}) + +test('different SQLite reader and writer files refuse even when configured within the same directory', async () => { + const f = await fixture(), + other = sqlite(join(f.directory, 'other.sqlite')) + try { + const expected = await prepareSnapshotJournalCaptureBackend(f.writer.client.config) + await expect( + withSnapshotJournalConnections( + f.writer, + other, + () => undefined, + async (write, read) => { + return await bindSnapshotJournalCaptureBackend(f.writer, write, other, read, expected) + }, + closeSnapshotJournalCapturePool + ) + ).rejects.toThrow('changed') + } finally { + await other.destroy() + await f.close() + } +}) + +test.each([ + { client: 'better-sqlite3', connection: { filename: ':memory:' } }, + { client: 'better-sqlite3', connection: { filename: 'file:shared?mode=memory' } }, + { client: 'mysql2', connection: async () => ({ database: 'wallet' }) }, + { client: 'mysql2', connection: { database: 'wallet' }, connectionPool: {} }, + { client: 'pg', connection: { database: 'wallet' } }, + { client: 'mysql', connection: { database: 'wallet' } }, + { client: 'mysql2', connection: { database: 7 } }, + { client: 'mysql2', connection: null } +])('unsupported backend configuration refuses before constructing an owned pool: %#', async config => { + await expect(prepareSnapshotJournalCaptureBackend(config as Knex.Config)).rejects.toThrow('unavailable') +}) + +let nativeConnectionId = 0 +function mysqlIdentity( + serverUuid: unknown, + databaseName: unknown, + connectionId: unknown = String(++nativeConnectionId) +) { + const owner = knex({ + client: 'mysql2', + connection: { database: 'synthetic' }, + pool: { min: 0, max: 1 } + }) + const query = owner.raw('SELECT 1') + query.connection = jest.fn().mockReturnValue(Promise.resolve([[{ serverUuid, databaseName, connectionId }]])) + jest.spyOn(owner.client, 'raw').mockReturnValue(query) + return owner +} +const uuid = 'aabbccdd-1234-4321-aabb-112233445566' + +test('MySQL binds the actual server and database returned by both exact reserved connections', async () => { + const writer = mysqlIdentity(uuid.toUpperCase(), 'synthetic'), + reader = mysqlIdentity(uuid, 'synthetic') + try { + const expected = await prepareSnapshotJournalCaptureBackend(writer.client.config) + expect(await bindSnapshotJournalCaptureBackend(writer, {}, reader, {}, expected)).toMatch(/^[0-9a-f]{64}$/) + } finally { + await writer.destroy() + await reader.destroy() + jest.restoreAllMocks() + } +}) + +test.each([ + ['different server', 'ffeeddcc-1234-4321-aabb-112233445566', 'synthetic'], + ['different database', uuid, 'other'], + ['invalid uuid', 'g'.repeat(36), 'synthetic'], + ['missing uuid', undefined, 'synthetic'], + ['empty database', uuid, ''], + ['oversized database', uuid, 'é'.repeat(129)] +])('MySQL actual identity refuses %s', async (_label, serverUuid, databaseName) => { + const writer = mysqlIdentity(uuid, 'synthetic'), + reader = mysqlIdentity(serverUuid, databaseName) + try { + await expect(bindSnapshotJournalCaptureBackend(writer, {}, reader, {}, { kind: 'mysql' })).rejects.toThrow( + 'changed' + ) + } finally { + await writer.destroy() + await reader.destroy() + jest.restoreAllMocks() + } +}) + +test('graceful MySQL quit does not establish physical cleanup until the socket close event', async () => { + const owner = knex({ + client: 'mysql2', + connection: { database: 'synthetic' }, + pool: { min: 0, max: 1 } + }) + const stream = new Duplex({ + read() {}, + write(_chunk, _encoding, callback) { + callback() + } + }) + const entered = gate(), + allow = gate() + jest.spyOn(owner.client, 'destroy').mockImplementation(async () => { + entered.resolve() + await allow.promise + }) + jest.spyOn(owner.client, 'releaseConnection').mockResolvedValue(undefined) + let closed = false + try { + const cleanup = closeSnapshotJournalCapturePool(owner, { stream }).then(() => { + closed = true + }) + await entered.promise + allow.resolve() + await Promise.resolve() + await Promise.resolve() + expect(closed).toBe(false) + expect(stream.listenerCount('close')).toBe(1) + stream.destroy() + await cleanup + expect(stream.closed).toBe(true) + expect(closed).toBe(true) + expect(stream.listenerCount('close')).toBe(0) + } finally { + allow.resolve() + stream.destroy() + jest.restoreAllMocks() + await owner.destroy() + } +}) + +test('physical cleanup refusal is typed and retains both source and native release failures', async () => { + const f = await fixture() + const release = f.writer.client.releaseConnection.bind(f.writer.client) + let held: unknown + jest.spyOn(f.writer.client, 'releaseConnection').mockRejectedValue(new Error('release failed')) + try { + const operation = withSnapshotJournalConnections( + f.writer, + f.reader, + () => undefined, + async write => { + held = write + throw new Error('source failed') + }, + async (owner, connection) => { + if (owner === f.writer) { + await owner.client.releaseConnection(connection) + } else await closeSnapshotJournalCapturePool(owner, connection) + } + ) + await expect(operation).rejects.toBeInstanceOf(SnapshotJournalConnectionCleanupError) + await expect(operation).rejects.toMatchObject({ + cause: { + errors: [ + expect.objectContaining({ message: 'source failed' }), + expect.objectContaining({ message: 'release failed' }) + ] + } + }) + } finally { + jest.restoreAllMocks() + if (held !== undefined) await release(held) + await f.close() + } +}) + +test.each(['0', '01', 1, '18446744073709551616'])( + 'MySQL refuses invalid actual native connection identity %p', + async id => { + const writer = mysqlIdentity(uuid, 'synthetic', id), + reader = mysqlIdentity(uuid, 'synthetic') + try { + const expected = await prepareSnapshotJournalCaptureBackend(writer.client.config) + await expect(bindSnapshotJournalCaptureBackend(writer, {}, reader, {}, expected)).rejects.toThrow('changed') + } finally { + await writer.destroy() + await reader.destroy() + jest.restoreAllMocks() + } + } +) + +test('distinct pool objects cannot publish two handles addressing the same native MySQL connection', async () => { + const writer = mysqlIdentity(uuid, 'synthetic', '42'), + reader = mysqlIdentity(uuid, 'synthetic', '42') + try { + const expected = await prepareSnapshotJournalCaptureBackend(writer.client.config) + await expect(bindSnapshotJournalCaptureBackend(writer, {}, reader, {}, expected)).rejects.toThrow('changed') + } finally { + await writer.destroy() + await reader.destroy() + jest.restoreAllMocks() + } +}) + +test('fresh native connection IDs do not change the durable backend binding', async () => { + const pools = [ + mysqlIdentity(uuid, 'synthetic', '1'), + mysqlIdentity(uuid, 'synthetic', '2'), + mysqlIdentity(uuid, 'synthetic', '3'), + mysqlIdentity(uuid, 'synthetic', '4') + ] + try { + const expected = await prepareSnapshotJournalCaptureBackend(pools[0].client.config) + expect(await bindSnapshotJournalCaptureBackend(pools[0], {}, pools[1], {}, expected)).toBe( + await bindSnapshotJournalCaptureBackend(pools[2], {}, pools[3], {}, expected) + ) + } finally { + await Promise.all(pools.map(pool => pool.destroy())) + jest.restoreAllMocks() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts new file mode 100644 index 000000000..481d8881b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts @@ -0,0 +1,135 @@ +import type { Knex } from 'knex' +import { createHash } from 'node:crypto' +import { lstat, realpath } from 'node:fs/promises' +import type { Duplex } from 'node:stream' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' + +interface FileIdentity { + path: string + device: string + inode: string +} +export type SnapshotJournalCaptureBackend = { kind: 'sqlite'; file: FileIdentity } | { kind: 'mysql' } + +function unavailable(): never { + throw new WERR_INVALID_OPERATION('Snapshot journal capture backend is unavailable or changed') +} +async function fileIdentity(filename: string): Promise { + const path = await realpath(filename) + const info = await lstat(path, { bigint: true }) + if (!info.isFile()) return unavailable() + return { path, device: info.dev.toString(), inode: info.ino.toString() } +} + +/** Call before constructing either fresh owned pool; existing open SQLite handles + * cannot establish file identity by rechecking only their configured pathname. */ +export async function prepareSnapshotJournalCaptureBackend( + config: Knex.Config +): Promise { + const connection: unknown = config.connection + if ( + connection === null || + typeof connection !== 'object' || + ('connectionPool' in config && config.connectionPool != null) + ) + return unavailable() + if (config.client === 'better-sqlite3') { + const filename = (connection as Knex.Sqlite3ConnectionConfig).filename + if (typeof filename !== 'string' || !filename || filename === ':memory:' || filename.startsWith('file:')) + return unavailable() + return { kind: 'sqlite', file: await fileIdentity(filename) } + } + if (config.client === 'mysql2' && 'database' in connection && typeof connection.database === 'string') + return { kind: 'mysql' } + return unavailable() +} +interface ActualBackendIdentity { + backend: unknown + connectionId?: string +} +async function actualIdentity( + k: Knex, + connection: unknown, + expected: SnapshotJournalCaptureBackend +): Promise { + if (expected.kind === 'sqlite') { + const rows: Array<{ name: string; file: string }> = await k.raw('PRAGMA database_list').connection(connection) + const main = rows.filter(row => row.name === 'main') + if (main.length !== 1 || typeof main[0].file !== 'string') return unavailable() + const actual = await fileIdentity(main[0].file) + if (JSON.stringify(actual) !== JSON.stringify(expected.file)) return unavailable() + return { backend: actual } + } + const [rows]: Array> = await k + .raw( + 'SELECT @@server_uuid AS serverUuid, DATABASE() AS databaseName, CAST(CONNECTION_ID() AS CHAR) AS connectionId' + ) + .connection(connection) + const row = rows.at(0) + if ( + rows.length !== 1 || + typeof row?.serverUuid !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(row.serverUuid) || + typeof row.databaseName !== 'string' || + !row.databaseName || + Buffer.byteLength(row.databaseName, 'utf8') > 256 || + typeof row.connectionId !== 'string' || + !/^[1-9]\d{0,19}$/.test(row.connectionId) || + BigInt(row.connectionId) > 18446744073709551615n + ) + return unavailable() + return { + backend: { serverUuid: row.serverUuid.toLowerCase(), database: row.databaseName }, + connectionId: row.connectionId + } +} + +/** Both actual native connections must address the same backend before a barrier. */ +export async function bindSnapshotJournalCaptureBackend( + writer: Knex, + writerConnection: unknown, + reader: Knex, + readerConnection: unknown, + expected: SnapshotJournalCaptureBackend +): Promise { + const write = await actualIdentity(writer, writerConnection, expected) + const read = await actualIdentity(reader, readerConnection, expected) + if ( + JSON.stringify(write.backend) !== JSON.stringify(read.backend) || + (expected.kind === 'mysql' && write.connectionId === read.connectionId) + ) + return unavailable() + return createHash('sha256') + .update('snapshot-journal-backend-v1\n') + .update(JSON.stringify([expected.kind, write.backend])) + .digest('hex') +} + +/** The pool is owned exclusively by one capture. Destroy it while releasing its + * reserved slot, and wait for native closure before reporting physical cleanup. */ +export async function closeSnapshotJournalCapturePool(k: Knex, value: unknown): Promise { + const connection = value as { open?: boolean; stream?: Duplex } + const stream = connection.stream + let listener: (() => void) | undefined + const nativeClose = + stream === undefined || stream.closed + ? undefined + : new Promise(resolve => { + listener = resolve + stream.once('close', resolve) + }) + try { + const results = await Promise.allSettled([k.destroy(), k.client.releaseConnection(value)]) + const failed = results.filter(result => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map(result => result.reason), + 'Snapshot capture pool did not close' + ) + await nativeClose + if (k.client.config.client === 'better-sqlite3' ? connection.open !== false : stream?.closed !== true) + throw new Error('Snapshot capture native connection did not close') + } finally { + if (listener !== undefined) stream!.removeListener('close', listener) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.test.ts new file mode 100644 index 000000000..fec82bf39 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.test.ts @@ -0,0 +1,143 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { reserveSnapshotJournalCaptureFence } from './SnapshotJournalCaptureFence' +import { installSnapshotJournalSqliteClock, SNAPSHOT_JOURNAL_SQLITE_ADVANCE } from './SnapshotJournalSqliteClock' +import { snapshotJournalRevision, MAX_SNAPSHOT_JOURNAL_REVISION } from './SnapshotJournalRevision' + +async function fixture(ceiling = '9223372036854775807') { + const directory = await mkdtemp(join(tmpdir(), 'ts569-capture-fence-')) + const filename = join(directory, 'wallet.sqlite') + const open = () => + knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + const writer = open(), + barrier = open(), + reader = open() + await writer.raw('PRAGMA journal_mode=WAL') + await barrier.raw('PRAGMA busy_timeout=0') + await installSnapshotJournalSqliteClock(writer, snapshotJournalRevision(ceiling)) + await writer.raw('CREATE TABLE source(id INTEGER PRIMARY KEY,value INTEGER NOT NULL)') + await writer('source').insert({ id: 1, value: 0 }) + await writer.raw( + 'CREATE TRIGGER observe_source AFTER UPDATE ON source BEGIN ' + SNAPSHOT_JOURNAL_SQLITE_ADVANCE + ' END' + ) + return { + writer, + barrier, + reader, + close: async () => { + await reader.destroy() + await barrier.destroy() + await writer.destroy() + await rm(directory, { recursive: true, force: true }) + } + } +} + +test('a reserved global fence separates independent committed writers and leaves the pinned view immutable', async () => { + const f = await fixture() + let view: Knex.Transaction | undefined + try { + await f.writer('source').update({ value: 1 }) + let fence: string | undefined + await f.barrier.transaction(async t => { + fence = await reserveSnapshotJournalCaptureFence(t) + expect(fence).toBe('2') + view = await f.reader.transaction() + expect(await view('source').first()).toEqual({ id: 1, value: 1 }) + expect((await view('snapshot_journal_clock').first()).revision).toBe(1) + }) + await f.writer('source').update({ value: 2 }) + expect((await f.writer('snapshot_journal_clock').first()).revision).toBe(3) + expect(await view!('source').first()).toEqual({ id: 1, value: 1 }) + expect(fence).toBe('2') + } finally { + await view?.rollback() + await f.close() + } +}) + +test('occupied writer barrier refuses promptly and does not consume a position or replay a writer', async () => { + const f = await fixture() + let writer: Knex.Transaction | undefined + try { + writer = await f.writer.transaction() + await writer('source').update({ value: 1 }) + const start = performance.now() + await expect(f.barrier.transaction(reserveSnapshotJournalCaptureFence)).rejects.toMatchObject({ + code: 'SQLITE_BUSY' + }) + expect(performance.now() - start).toBeLessThan(1000) + await writer.commit() + writer = undefined + expect((await f.writer('snapshot_journal_clock').first()).revision).toBe(1) + expect(await f.barrier.transaction(reserveSnapshotJournalCaptureFence)).toBe('2') + } finally { + await writer?.rollback() + await f.close() + } +}) + +test('a rolled-back fence cannot publish and its position is reused only before any receipt commit', async () => { + const f = await fixture() + try { + await expect( + f.barrier.transaction(async t => { + expect(await reserveSnapshotJournalCaptureFence(t)).toBe('1') + throw Error('rollback') + }) + ).rejects.toThrow('rollback') + expect((await f.writer('snapshot_journal_clock').first()).revision).toBe(0) + expect(await f.barrier.transaction(reserveSnapshotJournalCaptureFence)).toBe('1') + } finally { + await f.close() + } +}) + +test.each(['2', MAX_SNAPSHOT_JOURNAL_REVISION])( + 'capture exhaustion at %s persists disablement only on commit', + async ceiling => { + const f = await fixture(ceiling) + try { + await f.writer.raw('UPDATE snapshot_journal_clock SET revision=CAST(? AS INTEGER)', [ + (BigInt(ceiling) - 1n).toString() + ]) + expect(await f.barrier.transaction(reserveSnapshotJournalCaptureFence)).toBe(ceiling) + expect(await f.barrier.transaction(reserveSnapshotJournalCaptureFence)).toBeUndefined() + expect(await f.writer('snapshot_journal_clock').first('enabled', 'reason')).toEqual({ + enabled: 0, + reason: ceiling === MAX_SNAPSHOT_JOURNAL_REVISION ? 'revision-exhausted' : 'capacity-exhausted' + }) + await f.writer('source').update({ value: 1 }) + expect(await f.writer('source').first()).toEqual({ id: 1, value: 1 }) + } finally { + await f.close() + } + } +) + +test('capture refuses ambient nontransaction, unsupported driver and busy-waiting admission', async () => { + const f = await fixture() + try { + await expect(reserveSnapshotJournalCaptureFence(f.barrier)).rejects.toThrow('Invalid snapshot journal capture') + await expect( + reserveSnapshotJournalCaptureFence({ + isTransaction: true, + client: { config: { client: 'pg' } } + } as unknown as Knex) + ).rejects.toThrow('Invalid snapshot journal capture') + await f.barrier.raw('PRAGMA busy_timeout=1') + await expect(f.barrier.transaction(reserveSnapshotJournalCaptureFence)).rejects.toThrow( + 'Invalid snapshot journal capture' + ) + expect((await f.writer('snapshot_journal_clock').first()).revision).toBe(0) + } finally { + await f.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts new file mode 100644 index 000000000..45a6eb1de --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts @@ -0,0 +1,98 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { + compareSnapshotJournalRevisions, + MAX_SNAPSHOT_JOURNAL_REVISION, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' +import { snapshotJournalRevisionText } from './SnapshotJournalRevisionSql' +import { SNAPSHOT_JOURNAL_SQLITE_ADVANCE } from './SnapshotJournalSqliteClock' + +function invalid(): never { + throw new WERR_INVALID_OPERATION('Invalid snapshot journal capture barrier or clock') +} + +async function sqliteFence(k: Knex): Promise { + const timeouts: Array<{ timeout: number }> = await k.raw('PRAGMA busy_timeout') + const modes: Array<{ journal_mode: string }> = await k.raw('PRAGMA journal_mode') + if (timeouts.length !== 1 || timeouts[0].timeout !== 0 || modes.length !== 1 || modes[0].journal_mode !== 'wal') + return invalid() + // Obtain the write reservation before any read can establish an old view. + if ( + (await k('snapshot_journal_clock') + .where('id', 1) + .update({ revision: k.ref('revision') })) !== 1 + ) + return invalid() + const before = await k('snapshot_journal_clock') + .where('id', 1) + .select( + 'enabled', + { revision: snapshotJournalRevisionText(k, 'revision') }, + { ceiling: snapshotJournalRevisionText(k, 'ceiling') } + ) + .first() + if (!before || ![0, 1].includes(before.enabled)) return invalid() + const revision = snapshotJournalRevision(before.revision), + ceiling = snapshotJournalRevision(before.ceiling) + if (ceiling === '0' || compareSnapshotJournalRevisions(revision, ceiling) > 0) return invalid() + if (before.enabled === 0) return undefined + await k.raw(SNAPSHOT_JOURNAL_SQLITE_ADVANCE) + const after = await k('snapshot_journal_clock') + .where('id', 1) + .select('enabled', { revision: snapshotJournalRevisionText(k, 'revision') }) + .first() + if (!after || ![0, 1].includes(after.enabled)) return invalid() + const value = snapshotJournalRevision(after.revision) + if (after.enabled === 0) return undefined + if (BigInt(value) !== BigInt(revision) + 1n || compareSnapshotJournalRevisions(value, ceiling) > 0) return invalid() + return value +} + +async function mysqlFence(k: Knex): Promise { + const clock = await k('snapshot_journal_clock') + .where('id', 1) + .select({ ceiling: snapshotJournalRevisionText(k, 'ceiling') }) + .forUpdate() + .noWait() + .first() + if (!clock) return invalid() + const ceiling = snapshotJournalRevision(clock.ceiling) + if (ceiling === '0') return invalid() + // Current reads are required even if the caller earlier established an RR view. + if (await k('snapshot_journal_invalid').where('id', 1).forUpdate().noWait().first('id')) return undefined + await k('snapshot_journal_events').insert({}) + const [[allocated]]: Array> = await k.raw( + 'SELECT CAST(LAST_INSERT_ID() AS CHAR) revision' + ) + if (typeof allocated?.revision !== 'string' || !/^[1-9]\d{0,19}$/.test(allocated.revision)) return invalid() + const value = BigInt(allocated.revision) + await k('snapshot_journal_events').where('revision', allocated.revision).delete() + if (value > BigInt(ceiling)) { + await k('snapshot_journal_invalid') + .insert({ + id: 1, + reason: value > BigInt(MAX_SNAPSHOT_JOURNAL_REVISION) ? 'revision-exhausted' : 'capacity-exhausted' + }) + .onConflict('id') + .ignore() + return undefined + } + return snapshotJournalRevision(allocated.revision) +} + +/** Internal primitive for an owned, validated, complete generation. The caller + * must reserve both native connections before beginning this fresh transaction. + * The exclusive barrier remains held until its transaction ends. Pin the separate + * reader, record its receipt, and commit before publishing. Undefined means the + * event window is disabled/exhausted: commit its invalidation without publishing. + * This consumes one event position; it does not advance a retention floor. + */ +export async function reserveSnapshotJournalCaptureFence(k: Knex): Promise { + if (!k.isTransaction) return invalid() + const client = k.client.config.client + if (client === 'better-sqlite3' || client === 'sqlite3') return await sqliteFence(k) + if (client === 'mysql' || client === 'mysql2') return await mysqlFence(k) + return invalid() +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts new file mode 100644 index 000000000..37d3d498a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts @@ -0,0 +1,367 @@ +import { knex } from 'knex' +import { withSnapshotJournalConnections, SnapshotJournalConnectionCleanupError } from './SnapshotJournalConnections' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +function pool() { + return knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) +} +async function fixture() { + const writer = pool(), + reader = pool() + return { + writer, + reader, + close: async () => { + await writer.destroy() + await reader.destroy() + } + } +} + +test('both single-slot pools are reserved before work; release permits later independent work', async () => { + const f = await fixture() + try { + const started = deferred(), + finish = deferred() + const operation = withSnapshotJournalConnections( + f.writer, + f.reader, + () => undefined, + async (a, b) => { + expect(a).not.toBe(b) + expect(f.writer.client.pool.numUsed()).toBe(1) + expect(f.reader.client.pool.numUsed()).toBe(1) + await f.writer.raw('CREATE TABLE x (id INTEGER)').connection(a) + await f.reader.raw('CREATE TABLE y (id INTEGER)').connection(b) + started.resolve() + await finish.promise + return 7 + } + ) + await started.promise + expect(f.writer.client.pool.numUsed()).toBe(1) + finish.resolve() + expect(await operation).toBe(7) + expect(f.writer.client.pool.numUsed()).toBe(0) + expect(f.reader.client.pool.numUsed()).toBe(0) + await f.writer('x').insert({ id: 1 }) + await f.reader('y').insert({ id: 2 }) + } finally { + await f.close() + } +}) + +test.each(['writer', 'reader'] as const)('an occupied %s pool cannot run the barrier callback', async held => { + const f = await fixture() + const hold = f[held], + connection = await hold.client.acquireConnection() + try { + const other = held === 'writer' ? f.reader : f.writer + const acquired = deferred() + const acquire = other.client.acquireConnection.bind(other.client) + jest.spyOn(other.client, 'acquireConnection').mockImplementation(async () => { + const value = await acquire() + acquired.resolve() + return value + }) + let cancelled = false + const action = jest.fn(async () => 7) + const operation = withSnapshotJournalConnections( + f.writer, + f.reader, + () => { + if (cancelled) throw new Error('cancelled') + }, + action + ) + const rejection = expect(operation).rejects.toThrow('cancelled') + await acquired.promise + expect(action).not.toHaveBeenCalled() + cancelled = true + await hold.client.releaseConnection(connection) + await rejection + expect(action).not.toHaveBeenCalled() + expect(other.client.pool.numUsed()).toBe(0) + expect(hold.client.pool.numUsed()).toBe(0) + } finally { + await f.close() + } +}) + +test.each(['writer', 'reader'] as const)( + 'failed %s acquisition drains a late successful peer before rejecting', + async failed => { + const f = await fixture() + try { + const late = failed === 'writer' ? f.reader : f.writer + const allow = deferred(), + entered = deferred() + const acquire = late.client.acquireConnection.bind(late.client) + jest.spyOn(late.client, 'acquireConnection').mockImplementation(async () => { + entered.resolve() + await allow.promise + return await acquire() + }) + jest.spyOn(f[failed].client, 'acquireConnection').mockRejectedValue(new Error('acquire failed')) + let settled = false + const action = jest.fn(async () => 7) + const operation = withSnapshotJournalConnections(f.writer, f.reader, () => undefined, action) + void operation.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + const rejection = expect(operation).rejects.toThrow('Snapshot connection acquisition failed') + await entered.promise + expect(settled).toBe(false) + expect(action).not.toHaveBeenCalled() + allow.resolve() + await rejection + expect(late.client.pool.numUsed()).toBe(0) + expect(action).not.toHaveBeenCalled() + } finally { + await f.close() + } + } +) + +test('run failure still releases both pools and retains the original error', async () => { + const f = await fixture() + try { + const error = new Error('pin failed') + await expect( + withSnapshotJournalConnections( + f.writer, + f.reader, + () => undefined, + async () => { + throw error + } + ) + ).rejects.toBe(error) + expect(f.writer.client.pool.numUsed()).toBe(0) + expect(f.reader.client.pool.numUsed()).toBe(0) + } finally { + await f.close() + } +}) + +test('cleanup failure waits for the other release and preserves both failure causes', async () => { + const f = await fixture() + const owned: unknown[] = [] + const releaseWriter = f.writer.client.releaseConnection.bind(f.writer.client) + const releaseReader = f.reader.client.releaseConnection.bind(f.reader.client) + try { + const release = deferred(), + entered = deferred() + jest.spyOn(f.writer.client, 'releaseConnection').mockRejectedValue(new Error('writer release failed')) + jest.spyOn(f.reader.client, 'releaseConnection').mockImplementation(async value => { + entered.resolve() + await release.promise + await releaseReader(value) + }) + let settled = false + const operation = withSnapshotJournalConnections( + f.writer, + f.reader, + () => undefined, + async (a, b) => { + owned.push(a, b) + throw new Error('read failed') + } + ) + void operation.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + const rejection = expect(operation).rejects.toBeInstanceOf(SnapshotJournalConnectionCleanupError) + await entered.promise + expect(settled).toBe(false) + release.resolve() + await rejection + await expect(operation).rejects.toMatchObject({ + cause: { + errors: [ + expect.objectContaining({ message: 'read failed' }), + expect.objectContaining({ message: 'writer release failed' }) + ] + } + }) + expect(f.reader.client.pool.numUsed()).toBe(0) + } finally { + jest.restoreAllMocks() + if (owned.length) await releaseWriter(owned[0]) + await f.close() + } +}) + +test('already cancelled calls and shared pools refuse before native acquisition', async () => { + const f = await fixture() + try { + const spy = jest.spyOn(f.writer.client, 'acquireConnection') + await expect( + withSnapshotJournalConnections( + f.writer, + f.reader, + () => { + throw new Error('already cancelled') + }, + async () => 1 + ) + ).rejects.toThrow('already cancelled') + await expect( + withSnapshotJournalConnections( + f.writer, + f.writer, + () => undefined, + async () => 1 + ) + ).rejects.toThrow('independent owned') + expect(spy).not.toHaveBeenCalled() + } finally { + await f.close() + } +}) + +test('caller-owned transactions cannot be borrowed as new pool reservations', async () => { + const f = await fixture() + try { + await f.writer.transaction(async trx => { + await expect( + withSnapshotJournalConnections( + trx, + f.reader, + () => undefined, + async () => 1 + ) + ).rejects.toThrow('independent owned') + await expect( + withSnapshotJournalConnections( + f.reader, + trx, + () => undefined, + async () => 1 + ) + ).rejects.toThrow('independent owned') + }) + } finally { + await f.close() + } +}) + +test('generated acquisition schedules preserve admission and release every acquired native connection', async () => { + const fc = (await import('fast-check')).default + await fc.assert( + fc.asyncProperty( + fc.record({ + readerFirst: fc.boolean(), + failWriter: fc.boolean(), + failReader: fc.boolean(), + cancelBefore: fc.boolean(), + cancelWhileAcquiring: fc.boolean(), + failRun: fc.boolean() + }), + async schedule => { + const f = await fixture() + const writerGate = deferred(), + readerGate = deferred() + const writerSeen = deferred(), + readerSeen = deferred() + const acquired: string[] = [], + released: string[] = [] + let cancelled = schedule.cancelBefore + function prepare( + role: 'writer' | 'reader', + gate: ReturnType>, + seen: ReturnType>, + fail: boolean + ) { + const owner = f[role], + acquire = owner.client.acquireConnection.bind(owner.client), + release = owner.client.releaseConnection.bind(owner.client) + jest.spyOn(owner.client, 'acquireConnection').mockImplementation(async () => { + await gate.promise + if (fail) { + seen.resolve() + throw new Error(role + ' unavailable') + } + const connection = await acquire() + acquired.push(role) + seen.resolve() + return connection + }) + jest.spyOn(owner.client, 'releaseConnection').mockImplementation(async connection => { + await release(connection) + released.push(role) + }) + } + try { + prepare('writer', writerGate, writerSeen, schedule.failWriter) + prepare('reader', readerGate, readerSeen, schedule.failReader) + const action = jest.fn(async () => { + if (schedule.failRun) throw new Error('capture failed') + return 'captured' + }) + const operation = withSnapshotJournalConnections( + f.writer, + f.reader, + () => { + if (cancelled) throw new Error('cancelled') + }, + action + ) + const outcome = operation.then( + value => ({ ok: true, value }), + error => ({ ok: false, error }) + ) + if (!schedule.cancelBefore) { + const first = schedule.readerFirst ? [readerGate, readerSeen] : [writerGate, writerSeen] + first[0].resolve() + await first[1].promise + expect(action).not.toHaveBeenCalled() + cancelled = schedule.cancelWhileAcquiring + } + writerGate.resolve() + readerGate.resolve() + const result = await outcome + const admitted = + !schedule.cancelBefore && !schedule.cancelWhileAcquiring && !schedule.failWriter && !schedule.failReader + expect(action).toHaveBeenCalledTimes(Number(admitted)) + expect(result.ok).toBe(admitted && !schedule.failRun) + expect(new Set(released)).toEqual(new Set(acquired)) + expect(new Set(released).size).toBe(released.length) + expect(f.writer.client.pool.numUsed()).toBe(0) + expect(f.reader.client.pool.numUsed()).toBe(0) + } finally { + jest.restoreAllMocks() + await f.close() + } + } + ), + { + numRuns: 300, + seed: 3242026, + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}) + } + ) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.ts new file mode 100644 index 000000000..469b6e915 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.ts @@ -0,0 +1,72 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' + +export class SnapshotJournalConnectionCleanupError extends Error { + constructor(override readonly cause: unknown) { + super('Snapshot journal connection cleanup failed') + this.name = 'SnapshotJournalConnectionCleanupError' + } +} + +interface ReservedConnection { + owner: Knex + connection: unknown +} + +/** Reserve both dedicated pools before any caller can obtain a writer barrier. + * The surrounding retained-view lifecycle controls cancellation and admission: + * assertActive must reject after cancellation/expiry, and its closed promise + * must await this function even when opening has already rejected. These pools + * must be owned by that lifecycle, and destroyed after it settles. This helper + * does not establish matching backend identity, begin a transaction or publish + * a reader. Acquisition failures still drain the other pending acquisition. + */ +export async function withSnapshotJournalConnections( + writer: Knex, + reader: Knex, + assertActive: () => void, + run: (writerConnection: unknown, readerConnection: unknown) => Promise, + release: (owner: Knex, connection: unknown) => Promise = async (owner, connection) => { + await owner.client.releaseConnection(connection) + } +): Promise { + assertActive() + if (writer === reader || writer.client === reader.client || writer.isTransaction || reader.isTransaction) + throw new WERR_INVALID_OPERATION('Snapshot capture requires two independent owned connection pools') + const reserved: ReservedConnection[] = [] + async function reserve(owner: Knex): Promise { + const connection: unknown = await owner.client.acquireConnection() + reserved.push({ owner, connection }) + return connection + } + let result: { ok: true; value: T } | { ok: false; error: unknown } + try { + const [write, read] = await Promise.allSettled([reserve(writer), reserve(reader)]) + if (write.status === 'rejected' || read.status === 'rejected') + throw new AggregateError( + [write, read].filter(value => value.status === 'rejected').map(value => value.reason), + 'Snapshot connection acquisition failed' + ) + if (write.value === read.value) + throw new WERR_INVALID_OPERATION('Snapshot pools returned the same native connection') + assertActive() + result = { ok: true, value: await run(write.value, read.value) } + } catch (error) { + result = { ok: false, error } + } + const cleanup = await Promise.allSettled( + reserved.map(async ({ owner, connection }) => { + await release(owner, connection) + }) + ) + const failedCleanup = cleanup.filter(value => value.status === 'rejected') + if (failedCleanup.length) { + const errors = failedCleanup.map(value => value.reason) + if (!result.ok) errors.unshift(result.error) + throw new SnapshotJournalConnectionCleanupError( + new AggregateError(errors, 'Snapshot connection ownership did not drain') + ) + } + if (!result.ok) throw result.error + return result.value +} diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs index 5f1102461..fa2d296cd 100644 --- a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs @@ -6,7 +6,7 @@ const { join } = require('node:path') const assert = require('node:assert/strict') const { executable, context, image, validateContext, validateContainer } = require('./snapshotArchiveDocker.cjs') const { runInSeries } = require('../../out/src/utility/runInSeries.js') -const journalFixtureGroups = Object.freeze(['generation', 'server-crash', 'receipts']) +const journalFixtureGroups = Object.freeze(['generation', 'server-crash', 'receipts', 'capture']) const execute = (file, args, options) => new Promise((resolve, reject) => { execFile( @@ -21,12 +21,13 @@ const execute = (file, args, options) => }) function fixtureScript(group) { + if (group === 'capture') return 'snapshotJournalCaptureMysql.cjs' if (group === 'generation') return 'snapshotJournalMysql.cjs' if (group === 'server-crash') return 'snapshotJournalMysqlServerCrash.cjs' return 'snapshotJournalReceiptMysql.cjs' } function fixtureTimeout(group) { - if (group === 'generation') return 180000 + if (group === 'generation' || group === 'capture') return 180000 if (group === 'server-crash') return 240000 return 60000 } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs new file mode 100644 index 000000000..fe9f4bf2d --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs @@ -0,0 +1,218 @@ +const assert = require('node:assert/strict') +const { fork } = require('node:child_process') +const { mkdtemp, rm, readFile } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { + open, + StorageKnex, + StorageProvider, + seedArchiveClosure, + tables, + exact +} = require('./snapshotJournalMysqlConnection.cjs') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + installSnapshotJournalMysqlGeneration, + completeSnapshotJournalMysqlGeneration +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.js') +const { + copySnapshotJournalBootstrapPage +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalBootstrap.js') +const { readSnapshotJournalReceipt } = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js') +const { snapshotArchiveTables } = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveStore.js') +const identity = '02' + '11'.repeat(32) +const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } +async function killAt(phase, marker) { + const child = fork(join(__dirname, 'snapshotJournalCaptureMysqlChild.cjs'), [phase, marker], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: process.env + }) + let stderr = '' + child.stderr.on('data', data => { + stderr += data + }) + const timer = setTimeout(() => child.kill('SIGKILL'), 20000) + try { + const result = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + assert.equal(result.signal, 'SIGKILL', stderr) + assert.equal(await readFile(marker, 'utf8'), phase) + } finally { + clearTimeout(timer) + } +} +async function main() { + const k = open(), + peer = open(), + storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }), + results = [] + let failure + try { + await storage.migrate('capture native fixture', 'synthetic-capture-native') + await storage.makeAvailable() + const { user } = await storage.findOrInsertUser(identity), + { user: other } = await storage.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(storage, user.userId, other.userId) + await installSnapshotJournalMysqlGeneration(k, request.ceiling, request.receiptPolicy) + let finished = false + function* pages() { + for (let n = 0; n < 100 && !finished; n++) yield n + } + await runInSeries(pages(), async () => { + finished = (await copySnapshotJournalBootstrapPage(k, 1000000)).complete + }) + assert(finished) + await completeSnapshotJournalMysqlGeneration(k, request.ceiling, request.receiptPolicy) + await exact(k) + await runInSeries(['READ COMMITTED', 'REPEATABLE READ'], async isolation => { + const foreground = open(), + provider = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: foreground }) + try { + await provider.makeAvailable() + await foreground.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + await peer.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + const started = performance.now(), + view = await provider.openSnapshotJournalSource(identity, request), + pinned = [] + try { + assert.deepEqual( + await peer.transaction(t => readSnapshotJournalReceipt(t, view.receiptBinding, view.receipt)), + view.receipt + ) + await runInSeries(snapshotArchiveTables, async table => { + const page = await view.readPage(table) + assert(page.done) + assert(page.rows.length > 0) + pinned.push(page.rows) + }) + const writing = performance.now() + await runInSeries(tables, async table => { + await peer(table).update({ + created_at: new Date(isolation === 'READ COMMITTED' ? '2026-01-02T00:00:00Z' : '2026-01-03T00:00:00Z') + }) + }) + assert(performance.now() - writing < 5000, 'Writer remained blocked by the retained view') + await runInSeries(snapshotArchiveTables.entries(), async ([index, table]) => { + const page = await view.readPage(table) + assert.deepEqual(page.rows, pinned[index]) + for (const row of page.rows) if ('userId' in row) assert.equal(row.userId, user.userId) + }) + assert.deepEqual( + pinned[0].map(row => row.provenTxId), + [1, 3] + ) + assert.deepEqual( + pinned[1].map(row => row.provenTxReqId), + [1, 3] + ) + assert.deepEqual( + pinned[12].map(row => row.syncStateId), + [1, 3] + ) + await assert.rejects(provider.openSnapshotArchiveSource(identity), /already has/) + const rows = await peer('snapshot_journal_physical').select(peer.raw('CAST(revision AS CHAR) revision')) + assert( + rows.some(row => BigInt(row.revision) > BigInt(view.receipt.highWater)), + JSON.stringify({ + isolation, + highWater: view.receipt.highWater, + maxRevision: rows.reduce((v, row) => (BigInt(row.revision) > BigInt(v) ? row.revision : v), '0') + }) + ) + } finally { + await view.close() + } + assert.equal(view.isOpen, false) + const held = await peer.transaction() + try { + await held('snapshot_journal_clock').where('id', 1).forUpdate().first() + const lockStart = performance.now() + await assert.rejects( + provider.openSnapshotJournalSource(identity, request), + error => error.code === 'ER_LOCK_NOWAIT' + ) + assert(performance.now() - lockStart < 2000) + } finally { + await held.rollback() + } + const next = await provider.openSnapshotJournalSource(identity, request) + assert.notEqual(next.receipt.requestId, view.receipt.requestId) + assert.equal(next.receiptBinding.backend, view.receiptBinding.backend) + assert(BigInt(next.receipt.highWater) > BigInt(view.receipt.highWater)) + await next.close() + const before = (await peer('information_schema.PROCESSLIST').where('DB', 'ts569_snapshot')).length + const last = await provider.openSnapshotJournalSource(identity, request) + const during = (await peer('information_schema.PROCESSLIST').where('DB', 'ts569_snapshot')).length + assert.equal(during, before + 2) + await provider.destroy() + assert.equal(last.isOpen, false) + await last.closed + await assert.rejects(last.readPage('txLabels')) + let after = (await peer('information_schema.PROCESSLIST').where('DB', 'ts569_snapshot')).length + const closeDeadline = performance.now() + 5000 + function* closing() { + while (after !== before - 1 && performance.now() < closeDeadline) yield undefined + } + await runInSeries(closing(), async () => { + await new Promise(resolve => setTimeout(resolve, 20)) + after = (await peer('information_schema.PROCESSLIST').where('DB', 'ts569_snapshot')).length + }) + assert.equal(after, before - 1, 'Server did not retire all provider-owned connections within five seconds') + // A fresh provider is used for the next isolation cohort; foreground pool + // ownership was deliberately drained by the preceding shutdown proof. + results.push({ + isolation, + tables: 13, + receiptCommitted: true, + immutable: true, + writerProgress: true, + lockRefusal: true, + nativeShutdown: true, + milliseconds: Math.round(performance.now() - started) + }) + } finally { + await provider.destroy() + } + }) + const directory = await mkdtemp(join(tmpdir(), 'ts569-capture-kill-')) + try { + await runInSeries(['before-commit', 'after-commit', 'opened'], async phase => { + const before = (await peer('snapshot_journal_receipts')).length + await killAt(phase, join(directory, phase)) + assert.equal((await peer('snapshot_journal_receipts')).length, before + Number(phase !== 'before-commit')) + const view = await storage.openSnapshotJournalSource(identity, request) + await assert.rejects(storage.openSnapshotJournalSource(identity, request), /already has/) + assert.deepEqual( + await peer.transaction(t => readSnapshotJournalReceipt(t, view.receiptBinding, view.receipt)), + view.receipt + ) + await view.close() + await peer('tx_labels') + .where('txLabelId', 1) + .update({ label: 'after process loss ' + phase }) + await exact(k) + results.push({ processLoss: phase, receiptAtomic: true, newCapture: true, writerProgress: true }) + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + console.log(JSON.stringify({ fixture: 'journal-capture-provider', results })) + } catch (error) { + failure = { error } + } + const closed = await Promise.allSettled([storage.destroy(), k.destroy(), peer.destroy()]) + const errors = closed.filter(result => result.status === 'rejected').map(result => result.reason) + if (errors.length) + throw new AggregateError( + [...(failure === undefined ? [] : [failure.error]), ...errors], + 'MySQL capture fixture cleanup failed' + ) + if (failure !== undefined) throw failure.error +} +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysqlChild.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysqlChild.cjs new file mode 100644 index 000000000..240b82461 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysqlChild.cjs @@ -0,0 +1,24 @@ +const { open, StorageKnex, StorageProvider } = require('./snapshotJournalMysqlConnection.cjs') +const phase = process.argv[2], + marker = process.argv[3] +const identity = '02' + '11'.repeat(32) +const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } +const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: open() }) +const injection = require('./snapshotJournalCaptureProcessLoss.cjs')(phase, marker) +process.once('disconnect', () => process.exit(1)) +const deadline = setTimeout(() => process.exit(2), 20000) +deadline.unref() +storage + .openSnapshotJournalSource(identity, request) + .then(async view => { + if (phase === 'opened') injection.park() + await view.close() + throw Error('Did not reach capture boundary: ' + JSON.stringify({ phase, pools: injection.pools })) + }) + .catch(error => { + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await storage.destroy() + }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureProcessLoss.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureProcessLoss.cjs new file mode 100644 index 000000000..4dd321eeb --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureProcessLoss.cjs @@ -0,0 +1,48 @@ +const assert = require('node:assert/strict') +const { writeFileSync } = require('node:fs') + +// Instrument only pools constructed after the foreground provider in this +// disposable child. SQL hooks never enter production code. +module.exports = function captureProcessLoss(phase, marker) { + assert(['before-commit', 'after-commit', 'opened'].includes(phase)) + const native = require('knex'), + original = native.knex + let pools = 0 + const park = () => { + writeFileSync(marker, phase) + process.kill(process.pid, 'SIGKILL') + } + native.knex = (...args) => { + pools++ + const owned = original(...args) + let receiptWritten = false + owned.on('query', q => { + if (q.sql.startsWith('insert into `snapshot_journal_receipts`')) receiptWritten = true + if (receiptWritten && q.sql === 'COMMIT;' && phase === 'before-commit') park() + }) + // Internal transaction COMMIT bypasses query-response. Await both SQL and + // executionPromise before terminating, while the provider still awaits its + // commit call and cannot publish the source. + const transaction = owned.client.transaction + owned.client.transaction = function (...parameters) { + const trx = transaction.apply(this, parameters) + const commit = trx.commit + trx.commit = async function (...values) { + const result = await commit.apply(this, values) + if (receiptWritten && phase === 'after-commit') { + await this.transactor.executionPromise + park() + } + return result + } + return trx + } + return owned + } + return { + park, + get pools() { + return pools + } + } +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs new file mode 100644 index 000000000..c12251446 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs @@ -0,0 +1,166 @@ +const assert = require('node:assert/strict') +const { fork } = require('node:child_process') +const { mkdtemp, rm, readFile } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { + knex, + StorageKnex, + StorageProvider, + seedArchiveClosure, + tables, + exact +} = require('./snapshotJournalNativeFixture.cjs') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + installSnapshotJournalSqliteGeneration, + completeSnapshotJournalSqliteGeneration +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.js') +const { + copySnapshotJournalBootstrapPage +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalBootstrap.js') +const { readSnapshotJournalReceipt } = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js') +const { snapshotArchiveTables } = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveStore.js') +const identity = '02' + '11'.repeat(32) +const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } +const open = filename => + knex({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true, pool: { min: 1, max: 1 } }) +async function child(filename, phase, marker) { + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: open(filename) }) + const injection = require('./snapshotJournalCaptureProcessLoss.cjs')(phase, marker) + process.once('disconnect', () => process.exit(1)) + const deadline = setTimeout(() => process.exit(2), 20000) + deadline.unref() + try { + const view = await storage.openSnapshotJournalSource(identity, request) + if (phase === 'opened') injection.park() + await view.close() + throw Error('Did not reach capture boundary: ' + JSON.stringify({ phase, pools: injection.pools })) + } finally { + await storage.destroy() + } +} +async function killAt(filename, phase, marker) { + const killed = fork(__filename, ['child', filename, phase, marker], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + let stderr = '' + killed.stderr.on('data', data => { + stderr += data + }) + const timer = setTimeout(() => killed.kill('SIGKILL'), 20000) + try { + const result = await new Promise((resolve, reject) => { + killed.once('error', reject) + killed.once('exit', (code, signal) => resolve({ code, signal })) + }) + assert.equal(result.signal, 'SIGKILL', stderr) + assert.equal(await readFile(marker, 'utf8'), phase) + } finally { + clearTimeout(timer) + } +} +async function main() { + const directory = await mkdtemp(join(tmpdir(), 'ts569-capture-sqlite-')) + const filename = join(directory, 'wallet.sqlite'), + k = open(filename), + peer = open(filename) + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + const results = [] + let failure + try { + await k.raw('PRAGMA journal_mode=WAL') + await storage.migrate('capture native WAL fixture', 'synthetic-capture-native') + await storage.makeAvailable() + const { user } = await storage.findOrInsertUser(identity), + { user: other } = await storage.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(storage, user.userId, other.userId) + await installSnapshotJournalSqliteGeneration(k, request.ceiling, request.receiptPolicy) + let finished = false + function* pages() { + for (let n = 0; n < 100 && !finished; n++) yield n + } + await runInSeries(pages(), async () => { + finished = (await copySnapshotJournalBootstrapPage(k, 1000000)).complete + }) + assert(finished) + await completeSnapshotJournalSqliteGeneration(k, request.receiptPolicy) + await exact(k) + const view = await storage.openSnapshotJournalSource(identity, request), + pinned = [] + try { + assert.deepEqual( + await peer.transaction(t => readSnapshotJournalReceipt(t, view.receiptBinding, view.receipt)), + view.receipt + ) + await runInSeries(snapshotArchiveTables, async table => { + const page = await view.readPage(table) + assert(page.done) + assert(page.rows.length > 0) + pinned.push(page.rows) + }) + const started = performance.now() + await runInSeries(tables, async table => { + await peer(table).update({ created_at: '2026-01-02T00:00:00.000Z' }) + }) + assert(performance.now() - started < 5000, 'WAL writer remained blocked by a retained reader') + await runInSeries(snapshotArchiveTables.entries(), async ([index, table]) => { + assert.deepEqual((await view.readPage(table)).rows, pinned[index]) + }) + assert.deepEqual( + pinned[0].map(row => row.provenTxId), + [1, 3] + ) + assert.deepEqual( + pinned[1].map(row => row.provenTxReqId), + [1, 3] + ) + assert.deepEqual( + pinned[12].map(row => row.syncStateId), + [1, 3] + ) + results.push({ tables: 13, receiptCommitted: true, immutable: true, writerProgress: true }) + } finally { + await view.close() + } + await runInSeries(['before-commit', 'after-commit', 'opened'], async phase => { + const before = (await peer('snapshot_journal_receipts')).length + await killAt(filename, phase, join(directory, phase)) + assert.equal((await peer('snapshot_journal_receipts')).length, before + Number(phase !== 'before-commit')) + const next = await storage.openSnapshotJournalSource(identity, request) + assert.deepEqual( + await peer.transaction(t => readSnapshotJournalReceipt(t, next.receiptBinding, next.receipt)), + next.receipt + ) + await next.close() + await peer('tx_labels') + .where('txLabelId', 1) + .update({ label: 'after process loss ' + phase }) + await exact(k) + assert.deepEqual(await k.raw('PRAGMA foreign_key_check'), []) + results.push({ processLoss: phase, receiptAtomic: true, newCapture: true, writerProgress: true }) + }) + console.log(JSON.stringify({ fixture: 'journal-capture-WAL', results })) + } catch (error) { + failure = { error } + } + const closed = await Promise.allSettled([storage.destroy(), k.destroy(), peer.destroy()]) + const errors = closed.filter(result => result.status === 'rejected').map(result => result.reason) + try { + await rm(directory, { recursive: true, force: true }) + } catch (error) { + errors.push(error) + } + if (errors.length) + throw new AggregateError( + [...(failure === undefined ? [] : [failure.error]), ...errors], + 'WAL capture fixture cleanup failed' + ) + if (failure !== undefined) throw failure.error +} +module.exports = main +if (require.main === module) { + const run = process.argv[2] === 'child' ? child(...process.argv.slice(3)) : main() + run.catch(error => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs index 5609924ef..bfc14053f 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysql.cjs @@ -312,7 +312,7 @@ async function main() { readerAdvertised: false, limitations: [ 'client SIGKILL only; server-crash/replication/failover remains open', - 'registered forward migration,quota,receipt and receiver remain incomplete' + 'this generation fixture does not qualify capture; registered migration, floor/quota and receiver remain incomplete' ] }) ) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs index 26c1fc140..2fd04e10d 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMysqlServerCrash.cjs @@ -304,7 +304,7 @@ async function main() { readerAdvertised: false, limitations: [ 'tmpfs-backed single MySQL8.4 server; no machine power-loss,replication,PXC/failover or production performance acceptance', - 'registered migration,quota,receipt and receiver remain incomplete' + 'this server-crash fixture does not qualify capture; registered migration, floor/quota and receiver remain incomplete' ] }) ) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs index bbd227d80..4243587ea 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalReceiptMysql.cjs @@ -247,7 +247,7 @@ async function main() { productionAdoption: false, limitations: [ 'Synthetic receipt transactions do not establish full source-capture ordering', - 'Capture controller, floor/tombstone lifecycle and registered migration remain incomplete', + 'This synthetic receipt fixture does not qualify the separate capture controller; floor/tombstone lifecycle and registered migration remain incomplete', 'A receipt cannot reopen a killed retained read view' ] }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs index cd52e2924..b76c412f6 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs @@ -190,6 +190,7 @@ async function main() { } } ) + await require('./snapshotJournalCaptureSqlite.cjs')() console.log( JSON.stringify({ status: 'SQLite journal generation native WAL process-loss checks', @@ -198,7 +199,7 @@ async function main() { limitations: [ 'not yet a registered forward migration', 'no MySQL implicit-DDL lifecycle qualification', - 'no receipt/quota/receiver or platform-scale acceptance' + 'atomic floor/quota/receiver and platform-scale acceptance remain open' ] }) ) diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index 536f7f43d..b83c3aa73 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -58,7 +58,11 @@ const plans = new Map([ ['src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', 'sqlite-generation'], ['src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', 'mysql-intent'], ['src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts', 'mysql-generation'], - ['src/storage/snapshot/journal/SnapshotJournalReceipt.ts', 'receipts'] + ['src/storage/snapshot/journal/SnapshotJournalReceipt.ts', 'receipts'], + ['src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts', 'capture-fence'], + ['src/storage/snapshot/journal/SnapshotJournalConnections.ts', 'connections'], + ['src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', 'capture-backend'], + ['src/storage/snapshot/journal/SnapshotJournalCapture.ts', 'capture'] ]) } ], diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index d502f656c..50dea38b0 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -294,7 +294,11 @@ for (const [id, expected, fallback] of [ 'sqlite-generation', 'mysql-intent', 'mysql-generation', - 'receipts' + 'receipts', + 'capture-fence', + 'connections', + 'capture-backend', + 'capture' ], 'revision' ], diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 28aa48254..ca67918c5 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -257,7 +257,11 @@ test('journal mutation registration retains its complete source, canonical tests 'src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.ts', 'src/storage/snapshot/journal/SnapshotJournalMysqlIntent.ts', 'src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.ts', - 'src/storage/snapshot/journal/SnapshotJournalReceipt.ts' + 'src/storage/snapshot/journal/SnapshotJournalReceipt.ts', + 'src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts', + 'src/storage/snapshot/journal/SnapshotJournalConnections.ts', + 'src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', + 'src/storage/snapshot/journal/SnapshotJournalCapture.ts' ]) assert.deepEqual(target.additionalInputs, [ 'test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json', @@ -273,6 +277,10 @@ test('journal mutation registration retains its complete source, canonical tests 'test/storage/snapshotJournalMysql.cjs', 'test/storage/snapshotJournalMysqlServerCrash.cjs', 'test/storage/snapshotJournalReceiptMysql.cjs', + 'test/storage/snapshotJournalCaptureMysql.cjs', + 'test/storage/snapshotJournalCaptureMysqlChild.cjs', + 'test/storage/snapshotJournalCaptureProcessLoss.cjs', + 'test/storage/snapshotJournalCaptureSqlite.cjs', 'test/storage/snapshotJournalSqliteCrash.cjs', 'test/storage/runSnapshotJournalMysql.cjs', 'test/storage/snapshotArchiveDocker.cjs' diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index a520ea209..f012e85e5 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -259,3 +259,19 @@ fixtures exercise adoption and recovery. Explicit full-data deletion remains supported; normal downgrade refuses without removing standard records. This advances indexed selection/recovery within S2 and V2. It does not complete committed-change continuity, large-wallet performance or the remaining program. + +## Owned journal capture checkpoint + +The internal SQL capture controller now owns its writer/reader pools and keeps +provider admission through physical cleanup. It binds the actual backend and +pinned complete generation, profile and schema, commits an exact receipt before +publication, and verifies closure after releasing the writer barrier. WAL and +MySQL native fixtures cover process loss before commit, after durable commit and +after publication. These component proofs advance S2; they do not complete it. +Atomic continuity floors, bounded tombstone collection, runtime quotas, registered +journal migration/recovery, generation-aware delta payload pages and receiver +integration remain required. The other acceptance rows remain open. + +Main's Postgres storage contract is preserved through the legacy path. Snapshot +auxiliary migrations are explicit no-ops there and the associated capabilities +remain unavailable; future support requires new forward migrations. From 5af7ac1c7e303ea2971671275f01fe1070635aa8 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 14:43:38 -0700 Subject: [PATCH 092/127] Fix pre-install native contract and capture analyzer findings --- .../journal/SnapshotJournalCapture.ts | 36 ++++++++++--------- .../storage/snapshotJournalCaptureMysql.cjs | 8 +++-- .../snapshotJournalCaptureMysqlChild.cjs | 7 ++-- .../snapshotJournalCaptureProcessLoss.cjs | 6 ++-- .../storage/snapshotJournalCaptureSqlite.cjs | 12 ++++--- scripts/ci-orchestration.test.mjs | 24 ++++++------- specs/wallet/sync-portability-program.md | 8 +++++ 7 files changed, 56 insertions(+), 45 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts index 86739f06e..4c875509b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts @@ -101,17 +101,19 @@ async function prepareReader(writer: Knex, write: unknown, reader: Knex, read: u await reader.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY').connection(read) } } -async function capture( - storage: StorageKnex, - writer: Knex, - write: unknown, - read: unknown, - backend: string, - identityKey: string, - request: SnapshotJournalCaptureRequest, - assertActive: () => void, +interface CaptureOptions { + storage: StorageKnex + writer: Knex + write: unknown + read: unknown + backend: string + identityKey: string + request: SnapshotJournalCaptureRequest + assertActive: () => void hold: (trx: Knex.Transaction, captured: Captured) => Promise -): Promise { +} +async function capture(options: CaptureOptions): Promise { + const { storage, writer, write, read, backend, identityKey, request, assertActive, hold } = options const reader = storage.knex, transactions: Knex.Transaction[] = [] try { @@ -218,20 +220,20 @@ export function retainSnapshotJournalCapture( async (write, read) => { const backend = await bindSnapshotJournalCaptureBackend(writer!, write, storage!.knex, read, expected) assertActive() - await capture( - storage!, - writer!, + await capture({ + storage: storage!, + writer: writer!, write, read, backend, identityKey, request, assertActive, - async (trx, value) => { + hold: async (trx, value) => { captured = value await hold(trx) } - ) + }) }, closeSnapshotJournalCapturePool ) @@ -241,8 +243,8 @@ export function retainSnapshotJournalCapture( let cancelled = false try { assertActive() - } catch (stop) { - cancelled = stop === error + } catch (error_) { + cancelled = error_ === error } if (!cancelled) failure = { error } } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs index fe9f4bf2d..ac8e7b25b 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict') const { fork } = require('node:child_process') -const { mkdtemp, rm, readFile } = require('node:fs/promises') +const { mkdtemp, rm, readFile, open: openFile } = require('node:fs/promises') const { tmpdir } = require('node:os') const { join } = require('node:path') const { @@ -24,8 +24,9 @@ const { snapshotArchiveTables } = require('../../out/src/storage/snapshot/archiv const identity = '02' + '11'.repeat(32) const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } async function killAt(phase, marker) { - const child = fork(join(__dirname, 'snapshotJournalCaptureMysqlChild.cjs'), [phase, marker], { - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + const output = await openFile(marker, 'wx', 0o600) + const child = fork(join(__dirname, 'snapshotJournalCaptureMysqlChild.cjs'), [phase], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc', output.fd], env: process.env }) let stderr = '' @@ -42,6 +43,7 @@ async function killAt(phase, marker) { assert.equal(await readFile(marker, 'utf8'), phase) } finally { clearTimeout(timer) + await output.close() } } async function main() { diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysqlChild.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysqlChild.cjs index 240b82461..e5d060c76 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysqlChild.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysqlChild.cjs @@ -1,10 +1,9 @@ const { open, StorageKnex, StorageProvider } = require('./snapshotJournalMysqlConnection.cjs') -const phase = process.argv[2], - marker = process.argv[3] +const phase = process.argv[2] const identity = '02' + '11'.repeat(32) const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: open() }) -const injection = require('./snapshotJournalCaptureProcessLoss.cjs')(phase, marker) +const injection = require('./snapshotJournalCaptureProcessLoss.cjs')(phase) process.once('disconnect', () => process.exit(1)) const deadline = setTimeout(() => process.exit(2), 20000) deadline.unref() @@ -13,7 +12,7 @@ storage .then(async view => { if (phase === 'opened') injection.park() await view.close() - throw Error('Did not reach capture boundary: ' + JSON.stringify({ phase, pools: injection.pools })) + throw new Error('Did not reach capture boundary: ' + JSON.stringify({ phase, pools: injection.pools })) }) .catch(error => { console.error(error) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureProcessLoss.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureProcessLoss.cjs index 4dd321eeb..a391b51dd 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureProcessLoss.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureProcessLoss.cjs @@ -1,15 +1,17 @@ const assert = require('node:assert/strict') const { writeFileSync } = require('node:fs') +// The parent supplies an exclusive marker descriptor at stdio[4]; children +// cannot choose a filesystem write path through command-line arguments. // Instrument only pools constructed after the foreground provider in this // disposable child. SQL hooks never enter production code. -module.exports = function captureProcessLoss(phase, marker) { +module.exports = function captureProcessLoss(phase) { assert(['before-commit', 'after-commit', 'opened'].includes(phase)) const native = require('knex'), original = native.knex let pools = 0 const park = () => { - writeFileSync(marker, phase) + writeFileSync(4, phase) process.kill(process.pid, 'SIGKILL') } native.knex = (...args) => { diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs index c12251446..20206ac22 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict') const { fork } = require('node:child_process') -const { mkdtemp, rm, readFile } = require('node:fs/promises') +const { mkdtemp, rm, readFile, open: openFile } = require('node:fs/promises') const { tmpdir } = require('node:os') const { join } = require('node:path') const { @@ -25,9 +25,9 @@ const identity = '02' + '11'.repeat(32) const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } const open = filename => knex({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true, pool: { min: 1, max: 1 } }) -async function child(filename, phase, marker) { +async function child(filename, phase) { const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: open(filename) }) - const injection = require('./snapshotJournalCaptureProcessLoss.cjs')(phase, marker) + const injection = require('./snapshotJournalCaptureProcessLoss.cjs')(phase) process.once('disconnect', () => process.exit(1)) const deadline = setTimeout(() => process.exit(2), 20000) deadline.unref() @@ -35,13 +35,14 @@ async function child(filename, phase, marker) { const view = await storage.openSnapshotJournalSource(identity, request) if (phase === 'opened') injection.park() await view.close() - throw Error('Did not reach capture boundary: ' + JSON.stringify({ phase, pools: injection.pools })) + throw new Error('Did not reach capture boundary: ' + JSON.stringify({ phase, pools: injection.pools })) } finally { await storage.destroy() } } async function killAt(filename, phase, marker) { - const killed = fork(__filename, ['child', filename, phase, marker], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + const output = await openFile(marker, 'wx', 0o600) + const killed = fork(__filename, ['child', filename, phase], { stdio: ['ignore', 'ignore', 'pipe', 'ipc', output.fd] }) let stderr = '' killed.stderr.on('data', data => { stderr += data @@ -56,6 +57,7 @@ async function killAt(filename, phase, marker) { assert.equal(await readFile(marker, 'utf8'), phase) } finally { clearTimeout(timer) + await output.close() } } async function main() { diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 2e5ac1f49..0245b3dbb 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -3,7 +3,6 @@ import { spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { join } from 'node:path' import test from 'node:test' -import YAML from 'yaml' import { REPOSITORY_ROOT } from './repository-health.mjs' import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' @@ -434,20 +433,17 @@ function assertNativeWalletJob(wallet) { assert.match(wallet, /^ timeout-minutes: 40$/m) assert.match(wallet, /^ permissions:\n contents: read\n strategy:/m) assert.doesNotMatch(wallet, /continue-on-error/) - const job = YAML.parse(wallet)['coverage-wallet'] - assert.deepEqual(job.strategy.matrix.include, [ - { id: 'shard-1', shard: 1 }, - { id: 'shard-2', shard: 2 }, - { id: 'shard-3', shard: 3 }, - { id: 'shard-4', shard: 4 }, - { id: 'sync-http-0', latency: 0 }, - { id: 'sync-http-1000', latency: 1000 } - ]) - assert.equal( - job.services.postgres.image, - 'postgres@sha256:d5daad18926b71c3d663f358af0aea798670cb79fb550c106d19662a9d1627ef' + // This gate runs before dependency installation; validate the governed native + // job's explicit matrix and service block without loading a workspace parser. + assert.match( + wallet, + /^ strategy:\n fail-fast: false\n matrix:\n include:\n - \{ id: shard-1, shard: 1 \}\n - \{ id: shard-2, shard: 2 \}\n - \{ id: shard-3, shard: 3 \}\n - \{ id: shard-4, shard: 4 \}\n - \{ id: sync-http-0, latency: 0 \}\n - \{ id: sync-http-1000, latency: 1000 \}\n services:$/m + ) + assert.match( + wallet, + /^ postgres:\n image: postgres@sha256:d5daad18926b71c3d663f358af0aea798670cb79fb550c106d19662a9d1627ef(?: #[^\n]*)?$/m ) - assert.deepEqual(job.services.postgres.ports, ['5432:5432']) + assert.match(wallet, /^ ports:\n - 5432:5432\n options: >-$/m) } test('native snapshot process-loss proof uses the same-head build in exactly one required wallet shard', () => { diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index f012e85e5..b6c1846a8 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -275,3 +275,11 @@ integration remain required. The other acceptance rows remain open. Main's Postgres storage contract is preserved through the legacy path. Snapshot auxiliary migrations are explicit no-ops there and the associated capabilities remain unavailable; future support requires new forward migrations. + +The first hosted capture head exposed a dependency-installation mismatch in the +pre-install native-job contract and five analyzer findings. The contract now +checks the exact six-row matrix and pinned Postgres service without a workspace +YAML dependency. Disposable process-cut children receive an exclusive inherited +marker descriptor and cannot choose a filesystem write path through CLI input. +Local source controls and the native capture cuts qualify this remediation; +complete exact-head hosted success is still required before any ready claim. From c00ce0a67c0e7204fe261befdf55aa5a9f18ab4a Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 15:24:23 -0700 Subject: [PATCH 093/127] Add bounded journal retention and fix capture fixture file races --- docs/guides/wallet-sync-reliability.md | 35 +- docs/reference/package-api-migrations.md | 74 +-- governance/mutation-testing/targets.mjs | 10 +- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 9 +- packages/wallet/wallet-toolbox/README.md | 15 +- .../journal/SnapshotJournalCollection.test.ts | 519 ++++++++++++++++++ .../journal/SnapshotJournalCollection.ts | 274 +++++++++ .../journal/SnapshotJournalReceipt.test.ts | 178 ++++++ .../journal/SnapshotJournalReceipt.ts | 57 ++ .../test/storage/runSnapshotJournalMysql.cjs | 13 +- .../storage/snapshotJournalCaptureMysql.cjs | 8 +- .../storage/snapshotJournalCaptureSqlite.cjs | 10 +- .../storage/snapshotJournalRetentionChild.cjs | 47 ++ .../storage/snapshotJournalRetentionCuts.cjs | 133 +++++ .../storage/snapshotJournalRetentionMysql.cjs | 201 +++++++ .../snapshotJournalRetentionMysqlRc.cjs | 4 + .../snapshotJournalRetentionMysqlRr.cjs | 4 + .../snapshotJournalRetentionProcessLoss.cjs | 67 +++ .../snapshotJournalRetentionSqlite.cjs | 209 +++++++ .../storage/snapshotJournalSqliteCrash.cjs | 3 +- scripts/mutation-partitions.mjs | 3 +- scripts/mutation-partitions.test.mjs | 3 +- scripts/mutation-testing.test.mjs | 10 +- specs/wallet/sync-portability-program.md | 26 +- 25 files changed, 1852 insertions(+), 64 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionChild.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionCuts.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysql.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysqlRc.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysqlRr.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionProcessLoss.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionSqlite.cjs diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index f304a36a3..e0823971e 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -1105,19 +1105,44 @@ progress. MySQL additionally exercises READ COMMITTED and REPEATABLE READ, busy barrier refusal and native provider shutdown. These are local synthetic engine proofs; exact-head hosted, deployed/PXC and power-loss acceptance remain separate. -Atomic floor/tombstone lifecycle and generation-aware delta-page/receiver -integration remain unfinished. A durable prefix proof does not reopen a killed +Internal floor advancement reserves the exclusive global writer clock before +retention and receipt locks. A bounded full receipt scan (at most 129 rows) +validates every retained record and uses database-clock expiry. The floor is +monotonic and cannot pass either its fresh allocated prefix or any live receipt's +high-water mark. Expired receipts still occupy capacity until their separate +bounded collector commits. + +Internal tombstone collection scans at most 256 primary-key records per short +transaction, including live and newer rows before applying eligibility. Its +cursor binds the complete composite key, generation epoch, floor and stream; +changing the floor starts another pass. Exact revision/presence conditions guard +each deletion, and all selected metadata is validated first. MySQL current reads +use NOWAIT and explicit equality-prefix ranges; SQLite uses indexed tuple seeks +with exact UTF-8 order. The collector removes only old absent metadata, preserves +all thirteen source tables and does not promise physical file shrink. + +WAL and both MySQL isolation cohorts prove active-prefix pins, retained-view +immutability, revisions above the JavaScript safe-integer limit and bounded seek +plans. Twenty-four actual process-loss cuts cover floor commit, partial deletion, +collection commit and committed lost acknowledgements, then independently check +metadata/source preservation and writer progress. These remain synthetic engine +proofs, with production/PXC and power-loss acceptance separate. + +These primitives require the caller to validate complete owned DDL/source and +exclude migration before a fresh transaction. Provider-owned maintenance and +registered retention recovery remain unfinished, as does generation-aware +delta-page/receiver integration. A durable prefix proof does not reopen a killed database read transaction. This foundation adds no registered migration, public capability or reader advertisement. Its event-window invalidation is not a complete retention or -resource policy. Full quotas and continuity-floor ownership, +resource policy. Runtime quotas and provider continuity-floor ownership, generation-aware receiver/primary integration, and remaining remote/IndexedDB, streaming and staged-import acceptance remain unfinished. Do not infer full incremental continuity or completed issue #544 from these helpers. -The wallet-snapshot-journal mutation target owns all eighteen complete source -modules in sixteen execution parts, including the whole receipt, capture, native +The wallet-snapshot-journal mutation target owns all nineteen complete source +modules in seventeen execution parts, including the whole receipt, collection, capture, native backend, connection ownership and barrier modules. Every part retains the complete canonical journal tests and fixtures, including one governed property entry for exact revision/page, generated source-observer and committed receipt-state schedules. A minimum score of 90%, zero diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index cc5481c9e..9e27f4328 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. Internal floor/collection primitives require a validated complete owned generation and migration exclusion before fresh short transactions. Provider maintenance, runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. +- Release note: Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. Internal floor/collection primitives require a validated complete owned generation and migration exclusion before fresh short transactions. Provider maintenance, runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index fea5a8271..16fe22314 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -530,6 +530,13 @@ export function buildMutationTargets(repositoryRoot) { 'test/storage/snapshotJournalCaptureMysqlChild.cjs', 'test/storage/snapshotJournalCaptureProcessLoss.cjs', 'test/storage/snapshotJournalCaptureSqlite.cjs', + 'test/storage/snapshotJournalRetentionChild.cjs', + 'test/storage/snapshotJournalRetentionCuts.cjs', + 'test/storage/snapshotJournalRetentionMysql.cjs', + 'test/storage/snapshotJournalRetentionMysqlRc.cjs', + 'test/storage/snapshotJournalRetentionMysqlRr.cjs', + 'test/storage/snapshotJournalRetentionProcessLoss.cjs', + 'test/storage/snapshotJournalRetentionSqlite.cjs', 'test/storage/snapshotJournalSqliteCrash.cjs', 'test/storage/runSnapshotJournalMysql.cjs', 'test/storage/snapshotArchiveDocker.cjs' @@ -554,7 +561,8 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts', 'src/storage/snapshot/journal/SnapshotJournalConnections.ts', 'src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', - 'src/storage/snapshot/journal/SnapshotJournalCapture.ts' + 'src/storage/snapshot/journal/SnapshotJournalCapture.ts', + 'src/storage/snapshot/journal/SnapshotJournalCollection.ts' ], ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/journal/*.test.ts'], { maxTestRunnerReuse: 8, diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index e64663fd4..18e9d5905 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending." + "summary": "Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. Internal floor/collection primitives require a validated complete owned generation and migration exclusion before fresh short transactions. Provider maintenance, runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index b81d764f2..d783c6f4e 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,12 +6,19 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Add internal monotonic continuity-floor transactions and bounded primary-key + tombstone collection. Current receipt locks pin all live prefixes; collection + preserves live/newer records and all thirteen source tables. WAL/RC/RR fixtures + cover partial deletion, rollback and committed lost acknowledgements. Provider + maintenance integration, runtime quotas, registered recovery and full #544 + acceptance remain required; incremental reader advertisement stays off. + - Add internal owned journal capture for `better-sqlite3` WAL and static `mysql2`. Reserve both pools before the short writer barrier; pin generation/profile, commit the exact receipt and verify closure before publication. Retain source admission through physical cleanup and fence an unproved close. Native fixtures cover commit/publication process loss, immutable pages and writer progress. - Continuity floors, quotas, delta receiver integration and full #544 acceptance + Provider retention ownership, quotas, delta receiver integration and full #544 acceptance remain unfinished; no migration or incremental capability is advertised. - Preserve main's Postgres storage and legacy sync support. SQLite/MySQL snapshot diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 8b2665b2e..06181369e 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -71,10 +71,17 @@ closure verification. It requires an existing file-backed WAL database with construction. Physical cleanup retains provider admission, and an unproved close fences further sources. Run `pnpm test:snapshot-journal-crash` for the SQLite process-loss fixture and `pnpm test:snapshot-journal-mysql` for the isolated -MySQL client/server-process recovery fixtures. These helpers do not register a -migration or advertise incremental synchronization. Full retention quotas, -continuity floors, generation-aware delta pages and receiver/primary integration and the remaining issue #544 -acceptance work are still required; see the +MySQL client/server-process recovery fixtures. Internal floor transactions reserve +the global writer clock before current receipt locks and cannot pass a live +receipt's prefix. Tombstone collection examines at most 256 primary-key rows, +including live and newer records, and binds its cursor to one epoch, floor and +stream. Native WAL/RC/RR checks cover twenty-four real process-loss boundaries. +Callers must validate the complete owned generation and exclude migrations before +these short transactions; automatic provider maintenance remains unfinished. +These helpers do not register a migration or advertise incremental +synchronization. Runtime quotas, registered recovery, generation-aware delta pages +and receiver/primary integration and the remaining issue #544 acceptance work are +still required; see the [journal foundation](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#internal-journal-foundation-unadvertised). Required wallet CI shard 1 also runs both native journal entry points from the diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.test.ts new file mode 100644 index 000000000..92183b62f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.test.ts @@ -0,0 +1,519 @@ +import { knex, type Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL } from './SnapshotJournalSqliteClock' +import { SNAPSHOT_JOURNAL_SQLITE_GENERATION_DDL } from './SnapshotJournalSqliteGeneration' +import { SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL } from './SnapshotJournalSqliteObservers' +import { snapshotJournalReceiptDdl } from './SnapshotJournalReceipt' +import { snapshotJournalRevision } from './SnapshotJournalRevision' +import { + collectSnapshotJournalTombstones, + snapshotJournalCollectionQuery, + type SnapshotJournalCollectionRequest +} from './SnapshotJournalCollection' + +const epoch = '12345678-1234-4234-9234-123456789012' +const request = (stream: 'scope' | 'physical' = 'scope', limit = 256): SnapshotJournalCollectionRequest => ({ + epoch, + floor: snapshotJournalRevision('50'), + stream, + limit +}) +let k: Knex, directory: string +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'ts569-collection-')) + k = knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + await k.raw('PRAGMA journal_mode=WAL') + await k.raw('PRAGMA busy_timeout=0') + for (const ddl of [ + SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL, + SNAPSHOT_JOURNAL_SQLITE_GENERATION_DDL, + ...SNAPSHOT_JOURNAL_SQLITE_METADATA_DDL, + ...snapshotJournalReceiptDdl(k) + ]) + await k.raw(ddl) + await k('snapshot_journal_clock').insert({ id: 1, revision: 100, ceiling: 100000, enabled: 1, reason: null }) + await k('snapshot_journal_generation').insert({ + id: 1, + version: 1, + epoch, + source: 'a'.repeat(64), + ceiling: '100000', + complete: 1 + }) + await k('snapshot_journal_retention').insert({ id: 1, floor: '50', receiptLimit: 128, receiptLifetimeMs: 60000 }) +}) +afterEach(async () => { + await k.destroy() + await rm(directory, { recursive: true, force: true }) +}) +const scope = (id1: number, present: number, revision = 10) => ({ + tableId: 0, + userId: 1, + id1, + id2: 0, + exactText: '', + revision, + present +}) + +test('the bound counts live rows; later tombstones require another primary-key page', async () => { + const rows = Array.from({ length: 300 }, (_, i) => scope(i + 1, Number(i < 260), i >= 290 ? 60 : 10)) + for (let n = 0; n < rows.length; n += 100) await k('snapshot_journal_scope').insert(rows.slice(n, n + 100)) + const first = await k.transaction(t => collectSnapshotJournalTombstones(t, request())) + expect(first).toMatchObject({ examined: 256, removed: 0, complete: false }) + expect(await k('snapshot_journal_scope')).toHaveLength(300) + const second = await k.transaction(t => collectSnapshotJournalTombstones(t, { ...request(), after: first!.after })) + expect(second).toMatchObject({ examined: 44, removed: 30, complete: true }) + expect(await k('snapshot_journal_scope').orderBy('id1')).toEqual( + rows.filter(row => row.present === 1 || row.revision > 50) + ) + const sql = snapshotJournalCollectionQuery(k, { ...request(), after: first!.after }).toSQL() + const plan: Array<{ detail: string }> = await k.raw( + 'EXPLAIN QUERY PLAN ' + sql.sql, + sql.bindings as Knex.RawBinding[] + ) + expect(plan.some(row => /SEARCH j USING INDEX sqlite_autoindex_snapshot_journal_scope_1/.test(row.detail))).toBe(true) + expect(plan.some(row => /TEMP B-TREE/.test(row.detail))).toBe(false) + expect(sql.sql).not.toMatch(/where.*(?:present|revision)/) +}) + +test('physical keys preserve exact UTF-8 order and resume after deleted positions', async () => { + const texts = ['😀', 'é', 'Z', 'A'] + const rows = texts.map(exactText => ({ + tableId: 12, + id1: 1, + id2: 0, + exactText, + revision: 40, + generation: 30, + present: 0 + })) + await k('snapshot_journal_physical').insert(rows) + await k('snapshot_journal_scope').insert(scope(1, 0)) + const first = await k.transaction(t => collectSnapshotJournalTombstones(t, request('physical', 2))) + expect(first).toMatchObject({ examined: 2, removed: 2, complete: false, after: { key: { exactText: 'Z' } } }) + const second = await k.transaction(t => + collectSnapshotJournalTombstones(t, { ...request('physical', 2), after: first!.after }) + ) + expect(second).toMatchObject({ removed: 2, after: { key: { exactText: '😀' } } }) + const last = await k.transaction(t => + collectSnapshotJournalTombstones(t, { ...request('physical', 2), after: second!.after }) + ) + expect(last).toEqual({ examined: 0, removed: 0, complete: true, after: second!.after }) + expect(await k('snapshot_journal_scope')).toHaveLength(1) +}) + +test('epoch, stream and floor mismatches never collect another pass', async () => { + await k('snapshot_journal_scope').insert(scope(1, 0)) + const cursor = { ...request(), key: { tableId: 0, userId: 1, id1: 1, id2: 0, exactText: '' } } + for (const change of [ + { epoch: '12345678-1234-4234-9234-123456789013' }, + { floor: snapshotJournalRevision('49') }, + { stream: 'physical' as const } + ]) + await expect( + k.transaction(t => collectSnapshotJournalTombstones(t, { ...request(), after: { ...cursor, ...change } })) + ).rejects.toThrow() + await expect( + k.transaction(t => collectSnapshotJournalTombstones(t, { ...request(), floor: snapshotJournalRevision('49') })) + ).rejects.toThrow() + await k('snapshot_journal_generation').update({ complete: 0 }) + await expect(k.transaction(t => collectSnapshotJournalTombstones(t, request()))).rejects.toThrow() + expect(await k('snapshot_journal_scope')).toHaveLength(1) + expect((await k('snapshot_journal_clock').first()).revision).toBe(100) +}) + +test('collection rollback restores deleted metadata; a lost acknowledgement leaves its committed result', async () => { + await k('snapshot_journal_scope').insert([scope(1, 0), scope(2, 1)]) + await expect( + k.transaction(async t => { + await collectSnapshotJournalTombstones(t, request()) + throw new Error('before commit') + }) + ).rejects.toThrow('before commit') + expect(await k('snapshot_journal_scope')).toHaveLength(2) + await expect( + (async () => { + await k.transaction(t => collectSnapshotJournalTombstones(t, request())) + throw new Error('lost acknowledgement') + })() + ).rejects.toThrow('lost acknowledgement') + expect(await k('snapshot_journal_scope').select('id1')).toEqual([{ id1: 2 }]) +}) + +test.each([ + { present: 2 }, + { revision: 0 }, + { exactText: 'x'.repeat(401) }, + { exactText: Buffer.from([255]) }, + { id1: 0 } +])('corrupt metadata %o refuses the whole page before deletion', async change => { + await k('snapshot_journal_scope').insert([scope(1, 0), { ...scope(2, 0), ...change }]) + await expect(k.transaction(t => collectSnapshotJournalTombstones(t, request()))).rejects.toThrow() + expect(await k('snapshot_journal_scope')).toHaveLength(2) +}) + +test.each([0, 257, Number.MAX_SAFE_INTEGER + 1, '1', null])( + 'rejects invalid bound %p before mutating the clock', + async limit => { + await expect( + k.transaction(t => + collectSnapshotJournalTombstones(t, { ...request(), limit } as SnapshotJournalCollectionRequest) + ) + ).rejects.toThrow() + expect((await k('snapshot_journal_clock').first()).revision).toBe(100) + } +) + +test('a disabled generation never deletes metadata or advances its floor', async () => { + await k('snapshot_journal_scope').insert(scope(1, 0)) + await k('snapshot_journal_clock').update({ enabled: 0, reason: 'capacity-exhausted' }) + expect(await k.transaction(t => collectSnapshotJournalTombstones(t, request()))).toBeUndefined() + expect(await k('snapshot_journal_scope')).toHaveLength(1) + expect((await k('snapshot_journal_retention').first()).floor).toBe('50') +}) + +test('collection requires a caller-owned transaction before allocating a revision', async () => { + await expect(collectSnapshotJournalTombstones(k, request())).rejects.toThrow( + 'Invalid snapshot journal tombstone collection state' + ) + expect((await k('snapshot_journal_clock').first()).revision).toBe(100) +}) + +test('300 seeded independent ledgers preserve the live view across bounded passes', async () => { + let seed = 3242026 + const random = (bound: number) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % bound + } + for (let n = 0; n < 300; n++) { + const stream = n % 2 ? 'scope' : 'physical', + table = 'snapshot_journal_' + stream + const fields = + stream === 'scope' ? ['tableId', 'userId', 'id1', 'id2', 'exactText'] : ['tableId', 'id1', 'id2', 'exactText'] + const order = (a: Record, b: Record) => { + for (const field of fields) { + const difference = + field === 'exactText' + ? Buffer.compare(Buffer.from(a[field] as string), Buffer.from(b[field] as string)) + : (a[field] as number) - (b[field] as number) + if (difference) return difference + } + return 0 + } + const source = Array.from({ length: 16 }, (_unused, i) => { + const revision = [1, 49, 50, 51, 70][random(5)] + return { + tableId: random(13), + ...(stream === 'scope' ? { userId: random(3) + 1 } : {}), + id1: i + 1, + id2: random(4), + exactText: ['', 'A', 'Z', 'é', '😀', 'é'.repeat(200)][random(6)], + revision, + present: random(2), + ...(stream === 'physical' ? { generation: revision } : {}) + } + }).sort(order) + await k(table).delete() + await k(table).insert(source) + const bound = random(8) + 1 + let input = request(stream, bound), + examined = 0 + while (true) { + const position = input.after?.key + const expected = source.filter(row => position === undefined || order(row, { ...position }) > 0).slice(0, bound) + const result = await k.transaction(t => collectSnapshotJournalTombstones(t, input)) + expect(result).toMatchObject({ + examined: expected.length, + removed: expected.filter(row => row.present === 0 && row.revision <= 50).length, + complete: expected.length < bound + }) + examined += result!.examined + if (expected.length) { + const last = expected[expected.length - 1] + expect(result!.after!.key).toEqual( + Object.fromEntries(fields.map(field => [field, last[field as keyof typeof last]])) + ) + } + if (result!.complete) break + input = { ...input, after: result!.after } + } + expect(examined).toBe(source.length) + const retained = await k(table).select() + retained.sort(order) + expect(retained).toEqual(source.filter(row => row.present === 1 || row.revision > 50)) + } +}, 60000) + +test.each(['mysql', 'mysql2'])( + 'MySQL %s generates a bounded exact composite range with current locks', + async client => { + const mysql = knex({ client: 'mysql2' }) + mysql.client.config.client = client + try { + for (const stream of ['scope', 'physical'] as const) { + const key = { tableId: 12, ...(stream === 'scope' ? { userId: 41 } : {}), id1: 987, id2: 12, exactText: 'é' } + const input = { ...request(stream, 19), after: { epoch, floor: request().floor, stream, key } } + const sql = snapshotJournalCollectionQuery(mysql, input).toSQL() + const fields = + stream === 'scope' ? ['tableId', 'userId', 'id1', 'id2', 'exactText'] : ['tableId', 'id1', 'id2', 'exactText'] + const operands = stream === 'scope' ? [12, 41, 987, 12, Buffer.from('é')] : [12, 987, 12, Buffer.from('é')] + const ranges = fields.map( + (field, i) => + '(' + + [...fields.slice(0, i).map(prefix => '`j`.`' + prefix + '` = ?'), '`j`.`' + field + '` > ?'].join(' and ') + + ')' + ) + const where = '`j`.`tableId` >= ? and (' + ranges.join(' or ') + ')' + expect(sql.sql).toContain('FORCE INDEX (`PRIMARY`)') + expect(sql.sql).toContain( + 'where ' + + where + + ' order by ' + + fields.map(field => '`j`.`' + field + '` asc').join(', ') + + ' limit ? for update nowait' + ) + expect(sql.bindings).toEqual([12, ...fields.flatMap((_field, i) => operands.slice(0, i + 1)), 19]) + expect(sql.sql).toContain('substr(`j`.`exactText`,1,401)') + expect(sql.sql).toContain('substr(CAST(`j`.`revision` AS CHAR),1,20)') + if (stream === 'physical') expect(sql.sql).toContain('substr(CAST(`j`.`generation` AS CHAR),1,20)') + expect(sql.sql).not.toMatch(/where.*(?:present|revision)/) + } + } finally { + await mysql.destroy() + } + } +) + +test('unsupported drivers refuse before a collection query can be constructed', async () => { + for (const client of ['pg', 'mysql-compatible', '', undefined]) { + const unsupported = { client: { config: { client } } } as unknown as Knex + expect(() => snapshotJournalCollectionQuery(unsupported, request())).toThrow(WERR_INVALID_OPERATION) + } +}) + +test('all cursor envelopes and key bounds reject with the established error identity', () => { + const validKey = { tableId: 0, userId: 1, id1: 1, id2: 0, exactText: '' } + const validCursor = { epoch, floor: request().floor, stream: 'scope' as const, key: validKey } + const invalidRequests: unknown[] = [ + undefined, + null, + 1, + 'scope', + Object.assign([], request()), + { ...request(), epoch: null }, + { ...request(), epoch: 'x' + epoch }, + { ...request(), epoch: epoch + 'x' }, + { ...request(), epoch: epoch.replace('-4', '-3') }, + { ...request(), stream: 'unknown' }, + { ...request(), after: null }, + { ...request(), after: Object.assign([], validCursor) }, + { ...request(), after: { ...validCursor, key: null } }, + { ...request(), after: { ...validCursor, key: Object.assign([], validKey) } } + ] + for (const change of [ + { tableId: -1 }, + { tableId: 13 }, + { tableId: 0.5 }, + { userId: 0 }, + { userId: 1.5 }, + { userId: Number.MAX_SAFE_INTEGER + 1 }, + { id1: 0 }, + { id1: 1.5 }, + { id1: '1' }, + { id2: -1 }, + { id2: 0.5 }, + { exactText: null }, + { exactText: '\ud800' }, + { exactText: 'é'.repeat(201) } + ]) + invalidRequests.push({ ...request(), after: { ...validCursor, key: { ...validKey, ...change } } }) + invalidRequests.push({ ...request('physical'), after: { ...validCursor, stream: 'physical', key: validKey } }) + for (const input of invalidRequests) + expect(() => snapshotJournalCollectionQuery(k, input as SnapshotJournalCollectionRequest)).toThrow( + WERR_INVALID_OPERATION + ) + expect(() => + snapshotJournalCollectionQuery(k, { + ...request(), + after: { ...validCursor, key: { ...validKey, exactText: 'é'.repeat(200) } } + }) + ).not.toThrow() +}) + +test.each(['scope', 'physical'] as const)( + 'MySQL %s parses exact stored keys and deletes only the eligible row', + async stream => { + const mysql = knex({ client: 'mysql2' }), + queries: Array<{ sql: string; bindings: unknown[] }> = [] + const row = (exactText: string, revisionText: string, present: number | boolean) => ({ + tableId: 12, + ...(stream === 'scope' ? { userId: '41' } : {}), + id1: '987', + id2: '12', + exactBytes: Buffer.from(exactText), + revisionText, + present, + ...(stream === 'physical' ? { generationText: '30' } : {}) + }) + let records: Array> = [row('Z', '49', false), row('é', '51', false), row('😀', '49', true)] + let generations: Array> = [{ id: 1, version: 1, complete: 1, epoch }] + let floor = '50', + allocated = '9007199254740993', + deleted = 1 + function response(sql: string): unknown { + if (sql.includes('from `snapshot_journal_clock`')) return [{ ceiling: '9223372036854775807' }] + if (sql.includes('from `snapshot_journal_invalid`')) return [] + if (sql.startsWith('SELECT CAST(LAST_INSERT_ID()')) return [{ revision: allocated }] + if (sql.includes('from `snapshot_journal_generation`')) return generations + if (sql.includes('from `snapshot_journal_retention`')) + return [{ id: 1, floor, receiptLimit: 128, receiptLifetimeMs: '60000' }] + if (sql.includes('FORCE INDEX (`PRIMARY`)')) return records + if (sql.startsWith('delete from')) return { affectedRows: deleted } + if (sql.startsWith('insert into')) return { affectedRows: 1, insertId: 1 } + if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(sql)) return { affectedRows: 0 } + throw new Error('Unexpected synthetic MySQL query: ' + sql) + } + const connection = { + query( + q: { sql: string }, + bindings: unknown[], + callback: (error: Error | null, rows?: unknown, fields?: unknown[]) => void + ) { + queries.push({ sql: q.sql, bindings }) + callback(null, response(q.sql), []) + } + } + jest.spyOn(mysql.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(mysql.client, 'releaseConnection').mockResolvedValue(undefined) + const key = { tableId: 12, ...(stream === 'scope' ? { userId: 41 } : {}), id1: 987, id2: 12, exactText: 'A' }, + input = { ...request(stream, 4), after: { epoch, floor: request().floor, stream, key } } + try { + expect(await mysql.transaction(t => collectSnapshotJournalTombstones(t, input))).toEqual({ + examined: 3, + removed: 1, + complete: true, + after: { epoch, floor: request().floor, stream, key: { ...key, exactText: '😀' } } + }) + const deletes = queries.filter(q => q.sql.startsWith('delete from `snapshot_journal_' + stream + '`')) + expect(deletes).toHaveLength(1) + expect(deletes[0].bindings).toEqual([0, '49', 12, ...(stream === 'scope' ? [41] : []), 987, 12, Buffer.from('Z')]) + expect(deletes[0].sql).toContain('`revision` = CAST(? AS SIGNED)') + for (const table of ['clock', 'generation', 'retention']) + expect(queries.find(q => q.sql.includes('from `snapshot_journal_' + table + '`'))!.sql).toContain( + 'for update nowait' + ) + const gen = queries.find(q => q.sql.includes('from `snapshot_journal_generation`'))! + expect(gen.bindings).toEqual([2]) + expect(gen.sql).toContain('substr(`epoch`,1,37)') + const projection = ['tableId', ...(stream === 'scope' ? ['userId'] : []), 'id1', 'id2', 'present'] + expect( + queries + .find(q => q.sql.includes('FORCE INDEX (`PRIMARY`)'))! + .sql.startsWith('select ' + projection.map(field => '`j`.`' + field + '`').join(', ') + ', substr(') + ).toBe(true) + for (const invalid of [ + ' 987', + '987 ', + '0987', + '+987', + '987.0', + '9.87e2', + '0x3db', + 'Infinity', + '9007199254740992' + ]) { + queries.length = 0 + records = [{ ...row('Z', '49', 0), id1: invalid }] + await expect(mysql.transaction(t => collectSnapshotJournalTombstones(t, input))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + expect(queries.some(q => q.sql.startsWith('delete from `snapshot_journal_' + stream + '`'))).toBe(false) + } + const refuses = async () => { + queries.length = 0 + await expect(mysql.transaction(t => collectSnapshotJournalTombstones(t, input))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + expect(queries.some(q => q.sql.startsWith('delete from `snapshot_journal_' + stream + '`'))).toBe(false) + } + const validGeneration = { id: 1, version: 1, complete: 1, epoch } + for (const state of [ + [], + [validGeneration, validGeneration], + [{ ...validGeneration, id: 2 }], + [{ ...validGeneration, version: 2 }], + [{ ...validGeneration, complete: 0 }], + [{ ...validGeneration, epoch: epoch.slice(0, -1) + '3' }] + ]) { + generations = state + records = [row('Z', '50', 0)] + await refuses() + } + generations = [validGeneration] + for (const invalidFloor of ['49', '9007199254740994']) { + floor = invalidFloor + await refuses() + } + floor = '50' + allocated = '49' + await refuses() + allocated = '50' + records = [{ ...row('Z', '50', 0), ...(stream === 'physical' ? { generationText: '50' } : {}) }] + expect(await mysql.transaction(t => collectSnapshotJournalTombstones(t, input))).toMatchObject({ removed: 1 }) + allocated = '9007199254740993' + for (const change of [ + { tableId: '012' }, + { id2: '012' }, + ...(stream === 'scope' ? [{ userId: '041' }] : []), + { exactBytes: Uint8Array.from([255]) }, + { exactBytes: Buffer.alloc(401, 65) }, + { present: '0' }, + { present: 2 }, + { revisionText: '0' }, + ...(stream === 'physical' ? [{ generationText: '0' }, { generationText: '51' }] : []) + ]) { + records = [row('B', '49', 0), { ...row('Z', '50', 0), ...change }] + await refuses() + } + for (const badPage of [ + [row('B', '49', 0), row('B', '49', 0)], + [row('Z', '49', 0), row('B', '49', 0)], + [row('A', '49', 0)], + Array.from({ length: 5 }, (_v, i) => row('B' + i, '49', 0)), + ...['tableId', ...(stream === 'scope' ? ['userId'] : []), 'id1', 'id2'].map(field => [ + row('B', '49', 0), + { ...row('Z', '49', 0), [field]: field === 'tableId' ? 11 : 1 } + ]) + ]) { + records = badPage + await refuses() + } + records = [row('é'.repeat(200), '50', 0)] + expect(await mysql.transaction(t => collectSnapshotJournalTombstones(t, input))).toMatchObject({ removed: 1 }) + for (const affected of [0, 2]) { + deleted = affected + await expect(mysql.transaction(t => collectSnapshotJournalTombstones(t, input))).rejects.toThrow( + WERR_INVALID_OPERATION + ) + } + deleted = 1 + records = [] + expect(await mysql.transaction(t => collectSnapshotJournalTombstones(t, request(stream)))).toEqual({ + examined: 0, + removed: 0, + complete: true + }) + } finally { + await mysql.destroy() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.ts new file mode 100644 index 000000000..3b7b2683d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.ts @@ -0,0 +1,274 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { runInSeries } from '../../../utility/runInSeries' +import { lockSnapshotJournalRetention } from './SnapshotJournalReceipt' +import { reserveSnapshotJournalCaptureFence } from './SnapshotJournalCaptureFence' +import { + compareSnapshotJournalRevisions, + snapshotJournalRevision, + type SnapshotJournalRevision +} from './SnapshotJournalRevision' +import { snapshotJournalRevisionOperand } from './SnapshotJournalRevisionSql' + +export interface SnapshotJournalCollectionKey { + tableId: number + userId?: number + id1: number + id2: number + exactText: string +} +export interface SnapshotJournalCollectionCursor { + epoch: string + floor: SnapshotJournalRevision + stream: 'scope' | 'physical' + key: SnapshotJournalCollectionKey +} +export interface SnapshotJournalCollectionRequest { + epoch: string + floor: SnapshotJournalRevision + stream: 'scope' | 'physical' + after?: SnapshotJournalCollectionCursor + limit: number +} +function invalid(): never { + throw new WERR_INVALID_OPERATION('Invalid snapshot journal tombstone collection state') +} +function object(value: unknown): value is object { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} +function integer(value: unknown, minimum: number, maximum = Number.MAX_SAFE_INTEGER, stored = false): number { + if (stored && typeof value === 'string' && /^(0|[1-9]\d{0,15})$/.test(value)) value = Number(value) + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) return invalid() + return value +} +function text(value: unknown): string { + const binary = Buffer.isBuffer(value) || value instanceof Uint8Array ? value : undefined + const result = binary === undefined ? value : Buffer.from(binary).toString('utf8') + if (typeof result !== 'string') return invalid() + const bytes = Buffer.from(result, 'utf8') + if (bytes.length > 400 || bytes.toString('utf8') !== result) return invalid() + if (binary !== undefined && !bytes.equals(Buffer.from(binary))) return invalid() + return result +} +function key(value: SnapshotJournalCollectionKey, stream: 'scope' | 'physical'): SnapshotJournalCollectionKey { + if (!object(value)) return invalid() + const result: SnapshotJournalCollectionKey = { + tableId: integer(value.tableId, 0, 12), + id1: integer(value.id1, 1), + id2: integer(value.id2, 0), + exactText: text(value.exactText) + } + if (stream === 'scope') result.userId = integer(value.userId, 1) + else if (value.userId !== undefined) return invalid() + return result +} +function detached(value: SnapshotJournalCollectionRequest): SnapshotJournalCollectionRequest { + if (!object(value)) return invalid() + const { epoch, stream } = value + if (typeof epoch !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(epoch)) + return invalid() + if (stream !== 'scope' && stream !== 'physical') return invalid() + const result: SnapshotJournalCollectionRequest = { + epoch, + stream, + floor: snapshotJournalRevision(value.floor), + limit: integer(value.limit, 1, 256) + } + const after = value.after + if (after !== undefined) { + if (!object(after) || after.epoch !== epoch || after.floor !== result.floor || after.stream !== stream) + return invalid() + result.after = { epoch, stream, floor: result.floor, key: key(after.key, stream) } + } + return result +} +function local(k: Knex): boolean { + if (['better-sqlite3', 'sqlite3'].includes(k.client.config.client)) return true + if (['mysql2', 'mysql'].includes(k.client.config.client)) return false + return invalid() +} +const columns = (stream: 'scope' | 'physical') => + stream === 'scope' + ? (['tableId', 'userId', 'id1', 'id2', 'exactText'] as const) + : (['tableId', 'id1', 'id2', 'exactText'] as const) +function values( + position: SnapshotJournalCollectionKey, + stream: 'scope' | 'physical', + sqlite: boolean +): Array { + return columns(stream).map(field => + field === 'exactText' && !sqlite ? Buffer.from(position.exactText, 'utf8') : position[field]! + ) +} +function seek(query: Knex.QueryBuilder, k: Knex, request: SnapshotJournalCollectionRequest): void { + if (request.after === undefined) return + const fields = columns(request.stream).map(field => 'j.' + field), + operands = values(request.after.key, request.stream, local(k)) + if (local(k)) { + query.whereRaw('(' + fields.map(() => '??').join(',') + ') > (' + fields.map(() => '?').join(',') + ')', [ + ...fields, + ...operands + ]) + return + } + // Explicit equality-prefix ranges retain every component of MySQL's primary + // index range; do not filter by present/revision before this bounded scan. + query.where(fields[0], '>=', operands[0]).where(function () { + for (const [index, field] of fields.entries()) { + this.orWhere(function () { + for (let prefix = 0; prefix < index; prefix++) this.where(fields[prefix], operands[prefix]) + this.where(field, '>', operands[index]) + }) + } + }) +} +/** Primary-key scan bounds examined metadata, including live and newer rows. */ +export function snapshotJournalCollectionQuery(k: Knex, input: SnapshotJournalCollectionRequest): Knex.QueryBuilder { + const request = detached(input), + sqlite = local(k), + table = 'snapshot_journal_' + request.stream + const hint = sqlite ? '?? AS ?? INDEXED BY ??' : '?? AS ?? FORCE INDEX (??)' + const query = k + .from(k.raw(hint, [table, 'j', sqlite ? 'sqlite_autoindex_' + table + '_1' : 'PRIMARY'])) + .select( + ...columns(request.stream) + .filter(field => field !== 'exactText') + .map(field => 'j.' + field), + 'j.present' + ) + .select( + k.raw(sqlite ? "coalesce(substr(CAST(?? AS BLOB),1,401),'') AS ??" : 'substr(??,1,401) AS ??', [ + 'j.exactText', + 'exactBytes' + ]) + ) + .select( + k.raw(sqlite ? 'substr(CAST(?? AS TEXT),1,20) AS ??' : 'substr(CAST(?? AS CHAR),1,20) AS ??', [ + 'j.revision', + 'revisionText' + ]) + ) + .orderBy(columns(request.stream).map(field => 'j.' + field)) + .limit(request.limit) + if (sqlite) query.select(k.raw('typeof(??) AS ??', ['j.exactText', 'keyType'])) + else query.forUpdate().noWait() + if (request.stream === 'physical') + query.select( + k.raw(sqlite ? 'substr(CAST(?? AS TEXT),1,20) AS ??' : 'substr(CAST(?? AS CHAR),1,20) AS ??', [ + 'j.generation', + 'generationText' + ]) + ) + seek(query, k, request) + return query +} +interface Metadata { + key: SnapshotJournalCollectionKey + revision: SnapshotJournalRevision + present: boolean +} +function metadata(row: Record, stream: 'scope' | 'physical', sqlite: boolean): Metadata { + if (sqlite && row.keyType !== 'text') return invalid() + if (![0, 1, false, true].includes(row.present as number | boolean)) return invalid() + const position = key( + { + tableId: integer(row.tableId, 0, 12, true), + userId: stream === 'scope' ? integer(row.userId, 1, Number.MAX_SAFE_INTEGER, true) : undefined, + id1: integer(row.id1, 1, Number.MAX_SAFE_INTEGER, true), + id2: integer(row.id2, 0, Number.MAX_SAFE_INTEGER, true), + exactText: text(row.exactBytes) + }, + stream + ) + const revision = snapshotJournalRevision(row.revisionText) + if (revision === '0') return invalid() + if (stream === 'physical') { + const generation = snapshotJournalRevision(row.generationText) + if (generation === '0' || compareSnapshotJournalRevisions(generation, revision) > 0) return invalid() + } + return { key: position, revision, present: row.present === 1 || row.present === true } +} +async function generation(k: Knex, epoch: string): Promise { + const query = k('snapshot_journal_generation') + .select('id', 'version', 'complete', k.raw('substr(??,1,37) AS ??', ['epoch', 'epoch'])) + .limit(2) + if (!local(k)) query.forUpdate().noWait() + const rows = await query + if ( + rows.length !== 1 || + rows[0].id !== 1 || + rows[0].version !== 1 || + rows[0].complete !== 1 || + rows[0].epoch !== epoch + ) + return invalid() +} +function compare( + a: SnapshotJournalCollectionKey, + b: SnapshotJournalCollectionKey, + stream: 'scope' | 'physical' +): number { + for (const field of columns(stream)) { + const order = + field === 'exactText' + ? Buffer.compare(Buffer.from(a.exactText, 'utf8'), Buffer.from(b.exactText, 'utf8')) + : a[field]! - b[field]! + if (order !== 0) return order + } + return 0 +} +/** Internal primitive: caller validates complete owned DDL/source and excludes + * migration before starting this fresh short transaction. It never deletes + * source rows. A cursor binds one epoch, floor and stream; a later floor must + * start a new pass. Complete means end of this bounded primary-key pass, not + * physical file shrink or collection of events after that floor. + */ +export async function collectSnapshotJournalTombstones( + k: Knex, + input: SnapshotJournalCollectionRequest +): Promise< + | { + examined: number + removed: number + complete: boolean + after?: SnapshotJournalCollectionCursor + } + | undefined +> { + const request = detached(input) + if (!k.isTransaction) return invalid() + const highWater = await reserveSnapshotJournalCaptureFence(k) + if (highWater === undefined) return undefined + await generation(k, request.epoch) + const retention = await lockSnapshotJournalRetention(k) + if (retention.floor !== request.floor || compareSnapshotJournalRevisions(retention.floor, highWater) > 0) + return invalid() + const records: Array> = await snapshotJournalCollectionQuery(k, request) + if (records.length > request.limit) return invalid() + const rows = records.map(row => metadata(row, request.stream, local(k))) + let previous = request.after?.key + for (const row of rows) { + if (previous !== undefined && compare(previous, row.key, request.stream) >= 0) return invalid() + previous = row.key + } + let removed = 0 + await runInSeries(rows, async row => { + if (row.present || compareSnapshotJournalRevisions(row.revision, request.floor) > 0) return + const fields = columns(request.stream), + operands = values(row.key, request.stream, local(k)) + const query = k('snapshot_journal_' + request.stream) + .where('present', 0) + .where('revision', snapshotJournalRevisionOperand(k, row.revision)) + for (const [index, field] of fields.entries()) query.where(field, operands[index]) + if ((await query.delete()) !== 1) return invalid() + removed++ + }) + return { + examined: rows.length, + removed, + complete: rows.length < request.limit, + ...(previous === undefined + ? {} + : { after: { epoch: request.epoch, stream: request.stream, floor: request.floor, key: previous } }) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts index 31951774e..cfc5f1abd 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.test.ts @@ -1,8 +1,13 @@ import { knex, type Knex } from 'knex' import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL } from './SnapshotJournalSqliteClock' import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' import * as ArchiveClock from '../archive/SnapshotArchiveSql' import { + advanceSnapshotJournalFloor, snapshotJournalReceiptPolicy, snapshotJournalReceiptBinding, snapshotJournalReceiptDdl, @@ -412,3 +417,176 @@ test('request expiry must be an exact primitive number before database work', as ).rejects.toThrow(WERR_INVALID_OPERATION) expect(await k('snapshot_journal_receipts')).toEqual([]) }) + +describe('atomic continuity floor', () => { + let directory: string + beforeEach(async () => { + await k.destroy() + directory = await mkdtemp(join(tmpdir(), 'ts569-retention-')) + k = knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + await k.raw('PRAGMA journal_mode=WAL') + await k.raw('PRAGMA busy_timeout=0') + for (const sql of snapshotJournalReceiptDdl(k)) await k.raw(sql) + await k.raw(SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL) + await k('snapshot_journal_clock').insert({ id: 1, revision: '100', ceiling: '1000', enabled: 1, reason: null }) + await k('snapshot_journal_retention').insert({ id: 1, floor: '0', receiptLimit: 128, receiptLifetimeMs: 600000 }) + jest.spyOn(ArchiveClock, 'snapshotArchiveDatabaseNow').mockResolvedValue(2000) + }) + afterEach(async () => { + jest.restoreAllMocks() + await k.destroy() + await rm(directory, { recursive: true, force: true }) + }) + const revision = (n: number) => snapshotJournalRevision(String(n)) + async function stored(n: number, highWater: number, expiresAt = 3000) { + await k('snapshot_journal_receipts').insert({ + requestId: n.toString(16).padStart(64, '0'), + binding: 'a'.repeat(64), + highWater: String(highWater), + floor: '0', + expiresAt + }) + } + test('the lowest live receipt pins continuity, including receipts for other profiles', async () => { + await stored(1, 90) + await stored(2, 70) + await stored(3, 10, 2000) + const receipts = await k('snapshot_journal_receipts').orderBy('requestId') + await expect(k.transaction(t => advanceSnapshotJournalFloor(t, revision(71)))).rejects.toThrow() + expect((await k('snapshot_journal_clock').first()).revision).toBe(100) + expect((await k('snapshot_journal_retention').first()).floor).toBe('0') + expect(await k('snapshot_journal_receipts').orderBy('requestId')).toEqual(receipts) + expect(await k.transaction(t => advanceSnapshotJournalFloor(t, revision(70)))).toEqual({ + floor: '70', + highWater: '101', + liveReceipts: 2, + examined: 3 + }) + expect(await k.transaction(t => advanceSnapshotJournalFloor(t, revision(70)))).toEqual({ + floor: '70', + highWater: '102', + liveReceipts: 2, + examined: 3 + }) + expect(await k('snapshot_journal_receipts').orderBy('requestId')).toEqual(receipts) + }) + test('rejects backwards and future floors and requires a writer transaction', async () => { + await k('snapshot_journal_retention').update({ floor: '40' }) + await expect(advanceSnapshotJournalFloor(k, revision(40))).rejects.toThrow() + for (const floor of [39, 102]) + await expect(k.transaction(t => advanceSnapshotJournalFloor(t, revision(floor)))).rejects.toThrow() + expect((await k('snapshot_journal_retention').first()).floor).toBe('40') + expect((await k('snapshot_journal_clock').first()).revision).toBe(100) + }) + test('an exhausted generation commits invalidation without advancing continuity', async () => { + await k('snapshot_journal_clock').update({ revision: 1000 }) + expect(await k.transaction(t => advanceSnapshotJournalFloor(t, revision(50)))).toBeUndefined() + expect((await k('snapshot_journal_clock').first()).enabled).toBe(0) + expect((await k('snapshot_journal_retention').first()).floor).toBe('0') + }) + test('floor publication rolls back atomically and survives a lost committed acknowledgement', async () => { + await expect( + k.transaction(async t => { + await advanceSnapshotJournalFloor(t, revision(50)) + throw Error('before commit') + }) + ).rejects.toThrow('before commit') + expect((await k('snapshot_journal_retention').first()).floor).toBe('0') + await expect( + (async () => { + await k.transaction(t => advanceSnapshotJournalFloor(t, revision(50))) + throw Error('lost acknowledgement') + })() + ).rejects.toThrow('lost acknowledgement') + expect((await k('snapshot_journal_retention').first()).floor).toBe('50') + }) + test('bounded capacity scans refuse overfull storage, including expired rows', async () => { + await k('snapshot_journal_retention').update({ receiptLimit: 1 }) + await stored(1, 10, 1000) + await stored(2, 10, 1000) + const queries: string[] = [] + k.on('query', query => queries.push(query.sql)) + await expect(k.transaction(t => advanceSnapshotJournalFloor(t, revision(50)))).rejects.toThrow() + expect(queries.filter(sql => sql.includes('from `snapshot_journal_receipts`'))).toEqual([ + expect.stringMatching(/order by `requestId` asc limit \?$/) + ]) + expect((await k('snapshot_journal_retention').first()).floor).toBe('0') + }) + test('corrupt expired receipts also prevent floor publication', async () => { + await stored(1, 10, 1000) + await k('snapshot_journal_receipts').update({ binding: 'z'.repeat(64) }) + await expect(k.transaction(t => advanceSnapshotJournalFloor(t, revision(50)))).rejects.toThrow() + expect((await k('snapshot_journal_retention').first()).floor).toBe('0') + }) + test('an independently held writer makes floor admission fail without waiting', async () => { + const peer = knex({ + client: 'better-sqlite3', + connection: k.client.config.connection, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + await peer.raw('PRAGMA busy_timeout=0') + const held = await peer.transaction() + try { + await held('snapshot_journal_clock').where('id', 1).update({ revision: 100 }) + const start = performance.now() + await expect(k.transaction(t => advanceSnapshotJournalFloor(t, revision(50)))).rejects.toMatchObject({ + code: 'SQLITE_BUSY' + }) + expect(performance.now() - start).toBeLessThan(1000) + } finally { + await held.rollback() + await peer.destroy() + } + }) + + test('300 seeded independent ledgers preserve every live prefix and monotonic floor', async () => { + let seed = 3242026 + const random = (max: number) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max + } + for (let n = 0; n < 300; n++) { + const prior = random(101), + requested = random(151) + const proofs = Array.from({ length: random(4) }, (_, index) => ({ + requestId: (index + 1).toString(16).padStart(64, '0'), + binding: 'a'.repeat(64), + highWater: String(random(121) + 1), + floor: '0', + expiresAt: random(2) ? 2000 : 3000 + })) + await k('snapshot_journal_receipts').delete() + await k('snapshot_journal_clock').update({ revision: 100, enabled: 1, reason: null }) + await k('snapshot_journal_retention').update({ floor: String(prior) }) + if (proofs.length) await k('snapshot_journal_receipts').insert(proofs) + const live = proofs.filter(proof => proof.expiresAt > 2000) + const allowed = + requested >= prior && + requested <= 101 && + live.every( + proof => + Number(proof.highWater) >= prior && Number(proof.highWater) <= 101 && requested <= Number(proof.highWater) + ) + const advancing = k.transaction(t => advanceSnapshotJournalFloor(t, revision(requested))) + if (allowed) { + expect(await advancing).toEqual({ + floor: String(requested), + highWater: '101', + liveReceipts: live.length, + examined: proofs.length + }) + } else { + await expect(advancing).rejects.toThrow() + } + expect((await k('snapshot_journal_retention').first()).floor).toBe(String(allowed ? requested : prior)) + expect((await k('snapshot_journal_clock').first()).revision).toBe(allowed ? 101 : 100) + expect(await k('snapshot_journal_receipts').orderBy('requestId')).toEqual(proofs) + } + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts index 5af022a73..c62db5b10 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalReceipt.ts @@ -2,6 +2,7 @@ import type { Knex } from 'knex' import { createHash } from 'node:crypto' import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' import { snapshotArchiveDatabaseNow } from '../archive/SnapshotArchiveSql' +import { reserveSnapshotJournalCaptureFence } from './SnapshotJournalCaptureFence' import { compareSnapshotJournalRevisions, snapshotJournalRevision, @@ -286,3 +287,59 @@ export async function collectSnapshotJournalReceipts(k: Knex): Promise { .delete() return rows.length } + +/** Advance only inside a fresh caller-owned transaction on an already validated, + * complete generation. The global writer reservation precedes retention and + * receipt locks, matching capture's order. Undefined disables publication: the + * caller must commit the clock invalidation and cannot collect that generation. + * Expired receipts continue to occupy capacity until their bounded collector + * commits, but cannot pin continuity after database-clock expiry. + */ +export async function advanceSnapshotJournalFloor( + k: Knex, + requested: SnapshotJournalRevision +): Promise< + | { floor: SnapshotJournalRevision; highWater: SnapshotJournalRevision; liveReceipts: number; examined: number } + | undefined +> { + const floor = snapshotJournalRevision(requested) + transaction(k) + const highWater = await reserveSnapshotJournalCaptureFence(k) + if (highWater === undefined) return undefined + const state = await retention(k, true), + time = await now(k) + if ( + compareSnapshotJournalRevisions(state.floor, highWater) > 0 || + compareSnapshotJournalRevisions(floor, state.floor) < 0 || + compareSnapshotJournalRevisions(floor, highWater) > 0 + ) + return invalid() + const query = receiptQuery(k) + .orderBy('requestId') + .limit(state.receiptLimit + 1) + if (!local(k)) query.forUpdate().noWait() + const rows: Array> = await query + if (rows.length > state.receiptLimit) return invalid() + let liveReceipts = 0 + for (const row of rows) { + const existing = receipt(row, true) + if (compareSnapshotJournalRevisions(existing.floor, state.floor) > 0) return invalid() + if (existing.expiresAt > time) { + if ( + compareSnapshotJournalRevisions(existing.highWater, state.floor) < 0 || + compareSnapshotJournalRevisions(existing.highWater, highWater) > 0 || + compareSnapshotJournalRevisions(floor, existing.highWater) > 0 + ) + return invalid() + liveReceipts++ + } + } + if ((await k('snapshot_journal_retention').where('id', 1).where('floor', state.floor).update({ floor })) !== 1) + return invalid() + return { floor, highWater, liveReceipts, examined: rows.length } +} + +/** Internal collector lock; callers reserve the global writer clock first. */ +export async function lockSnapshotJournalRetention(k: Knex): Promise { + return await retention(k, true) +} diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs index fa2d296cd..398ebe34a 100644 --- a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs @@ -6,7 +6,14 @@ const { join } = require('node:path') const assert = require('node:assert/strict') const { executable, context, image, validateContext, validateContainer } = require('./snapshotArchiveDocker.cjs') const { runInSeries } = require('../../out/src/utility/runInSeries.js') -const journalFixtureGroups = Object.freeze(['generation', 'server-crash', 'receipts', 'capture']) +const journalFixtureGroups = Object.freeze([ + 'generation', + 'server-crash', + 'receipts', + 'capture', + 'retention-rc', + 'retention-rr' +]) const execute = (file, args, options) => new Promise((resolve, reject) => { execFile( @@ -21,13 +28,15 @@ const execute = (file, args, options) => }) function fixtureScript(group) { + if (group === 'retention-rc') return 'snapshotJournalRetentionMysqlRc.cjs' + if (group === 'retention-rr') return 'snapshotJournalRetentionMysqlRr.cjs' if (group === 'capture') return 'snapshotJournalCaptureMysql.cjs' if (group === 'generation') return 'snapshotJournalMysql.cjs' if (group === 'server-crash') return 'snapshotJournalMysqlServerCrash.cjs' return 'snapshotJournalReceiptMysql.cjs' } function fixtureTimeout(group) { - if (group === 'generation' || group === 'capture') return 180000 + if (group === 'generation' || group === 'capture' || group.startsWith('retention-')) return 180000 if (group === 'server-crash') return 240000 return 60000 } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs index ac8e7b25b..da7a4fe7f 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureMysql.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict') const { fork } = require('node:child_process') -const { mkdtemp, rm, readFile, open: openFile } = require('node:fs/promises') +const { mkdtemp, rm, open: openFile } = require('node:fs/promises') const { tmpdir } = require('node:os') const { join } = require('node:path') const { @@ -24,7 +24,7 @@ const { snapshotArchiveTables } = require('../../out/src/storage/snapshot/archiv const identity = '02' + '11'.repeat(32) const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } async function killAt(phase, marker) { - const output = await openFile(marker, 'wx', 0o600) + const output = await openFile(marker, 'wx+', 0o600) const child = fork(join(__dirname, 'snapshotJournalCaptureMysqlChild.cjs'), [phase], { stdio: ['ignore', 'ignore', 'pipe', 'ipc', output.fd], env: process.env @@ -40,7 +40,9 @@ async function killAt(phase, marker) { child.once('exit', (code, signal) => resolve({ code, signal })) }) assert.equal(result.signal, 'SIGKILL', stderr) - assert.equal(await readFile(marker, 'utf8'), phase) + const bytes = Buffer.alloc(64) + const { bytesRead } = await output.read(bytes, 0, bytes.length, 0) + assert.equal(bytes.subarray(0, bytesRead).toString('utf8'), phase) } finally { clearTimeout(timer) await output.close() diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs index 20206ac22..fa88afe90 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalCaptureSqlite.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict') const { fork } = require('node:child_process') -const { mkdtemp, rm, readFile, open: openFile } = require('node:fs/promises') +const { mkdtemp, rm, open: openFile } = require('node:fs/promises') const { tmpdir } = require('node:os') const { join } = require('node:path') const { @@ -41,7 +41,7 @@ async function child(filename, phase) { } } async function killAt(filename, phase, marker) { - const output = await openFile(marker, 'wx', 0o600) + const output = await openFile(marker, 'wx+', 0o600) const killed = fork(__filename, ['child', filename, phase], { stdio: ['ignore', 'ignore', 'pipe', 'ipc', output.fd] }) let stderr = '' killed.stderr.on('data', data => { @@ -54,7 +54,9 @@ async function killAt(filename, phase, marker) { killed.once('exit', (code, signal) => resolve({ code, signal })) }) assert.equal(result.signal, 'SIGKILL', stderr) - assert.equal(await readFile(marker, 'utf8'), phase) + const bytes = Buffer.alloc(64) + const { bytesRead } = await output.read(bytes, 0, bytes.length, 0) + assert.equal(bytes.subarray(0, bytesRead).toString('utf8'), phase) } finally { clearTimeout(timer) await output.close() @@ -159,7 +161,7 @@ async function main() { if (failure !== undefined) throw failure.error } module.exports = main -if (require.main === module) { +if (require.main?.filename === __filename) { const run = process.argv[2] === 'child' ? child(...process.argv.slice(3)) : main() run.catch(error => { console.error(error) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionChild.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionChild.cjs new file mode 100644 index 000000000..eddde7fa5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionChild.cjs @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict') +const input = JSON.parse(process.argv[2]) +const { advanceSnapshotJournalFloor } = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js') +const { + collectSnapshotJournalTombstones +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalCollection.js') +const { inject } = require('./snapshotJournalRetentionProcessLoss.cjs') +async function main() { + assert(['sqlite', 'mysql'].includes(input.backend)) + const k = + input.backend === 'mysql' + ? require('./snapshotJournalMysqlConnection.cjs').open(true) + : require('knex').knex({ + client: 'better-sqlite3', + connection: { filename: input.filename }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) + process.once('disconnect', () => process.exit(1)) + const deadline = setTimeout(() => process.exit(2), 20000) + deadline.unref() + try { + if (input.backend === 'sqlite') await k.raw('PRAGMA busy_timeout=0') + else { + assert(['READ COMMITTED', 'REPEATABLE READ'].includes(input.isolation)) + await k.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + input.isolation) + } + inject(k, input.operation, input.phase) + await k.transaction(t => + input.operation === 'floor' + ? advanceSnapshotJournalFloor(t, input.floor) + : collectSnapshotJournalTombstones(t, input.request) + ) + throw new Error('Did not reach retention process-loss boundary') + } catch (error) { + try { + await k.destroy() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Retention child cleanup failed') + } + throw error + } +} +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionCuts.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionCuts.cjs new file mode 100644 index 000000000..42749fdcc --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionCuts.cjs @@ -0,0 +1,133 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { tables, exact } = require('./snapshotJournalNativeFixture.cjs') +const { killAt } = require('./snapshotJournalRetentionProcessLoss.cjs') +const { + advanceSnapshotJournalFloor, + collectSnapshotJournalReceipts +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js') +const { + collectSnapshotJournalTombstones +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalCollection.js') +const { snapshotArchiveDatabaseNow } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveSql.js') +const { snapshotJournalRevisionText } = require('../../out/src/storage/snapshot/journal/SnapshotJournalRevisionSql.js') +async function source(k) { + const rows = {} + await runInSeries(tables, async table => { + rows[table] = await k(table) + }) + return rows +} +async function newest(k) { + let prefix = '0' + await runInSeries(['scope', 'physical'], async stream => { + const table = 'snapshot_journal_' + stream + const row = await k(table) + .select({ revisionText: snapshotJournalRevisionText(k, 'revision') }) + .orderBy(table + '.revision', 'desc') + .first() + if (row !== undefined && BigInt(row.revisionText) > BigInt(prefix)) prefix = row.revisionText + }) + assert(BigInt(prefix) > 0n) + return prefix +} +module.exports = async function cuts(k, input) { + await k('snapshot_journal_receipts').update({ expiresAt: (await snapshotArchiveDatabaseNow(k)) - 1 }) + await k.transaction(t => collectSnapshotJournalReceipts(t)) + assert.equal((await k('snapshot_journal_receipts')).length, 0) + const results = [] + const clock = async () => + ( + await k('snapshot_journal_clock') + .select({ revision: snapshotJournalRevisionText(k, 'revision') }) + .first() + ).revision + await runInSeries(['before-commit', 'after-commit'], async phase => { + await k('tx_labels') + .where('txLabelId', 1) + .update({ label: 'floor process loss ' + phase }) + const floor = await newest(k), + previous = String((await k('snapshot_journal_retention').first()).floor), + beforeClock = input.backend === 'sqlite' ? await clock() : undefined + const beforeSource = await source(k) + await killAt({ ...input, operation: 'floor', phase, floor, marker: join(input.directory, 'floor-' + phase) }) + assert.equal( + String((await k('snapshot_journal_retention').first()).floor), + phase === 'after-commit' ? floor : previous + ) + if (input.backend === 'sqlite') + assert.equal(await clock(), phase === 'after-commit' ? String(BigInt(beforeClock) + 1n) : beforeClock) + assert.deepEqual(await source(k), beforeSource) + const recovered = await k.transaction(t => advanceSnapshotJournalFloor(t, floor)) + assert.equal(recovered.floor, floor) + await exact(k) + results.push({ operation: 'floor', phase, atomic: true, lostAcknowledgementRecovered: phase === 'after-commit' }) + }) + let firstId = 1000 + await runInSeries(['scope', 'physical'], async stream => { + await runInSeries(['after-first-delete', 'before-commit', 'after-commit'], async phase => { + const ids = [firstId++, firstId++, firstId++], + date = input.backend === 'mysql' ? new Date('2026-01-01T00:00:00Z') : '2026-01-01T00:00:00.000Z' + await k('tx_labels').insert( + ids.map(id => ({ + txLabelId: id, + userId: input.userId, + label: 'gc process loss ' + id, + isDeleted: false, + created_at: date, + updated_at: date + })) + ) + await k('tx_labels').whereIn('txLabelId', ids).delete() + const floor = await newest(k) + await k.transaction(t => advanceSnapshotJournalFloor(t, floor)) + const key = { + tableId: 3, + ...(stream === 'scope' ? { userId: input.userId } : {}), + id1: ids[0] - 1, + id2: 0, + exactText: '' + } + const request = { epoch: input.epoch, floor, stream, limit: 3, after: { epoch: input.epoch, floor, stream, key } } + const table = 'snapshot_journal_' + stream, + order = + stream === 'scope' ? ['tableId', 'userId', 'id1', 'id2', 'exactText'] : ['tableId', 'id1', 'id2', 'exactText'] + const before = await k(table).orderBy(order), + beforeSource = await source(k), + beforeClock = input.backend === 'sqlite' ? await clock() : undefined + const deleted = row => + Number(row.tableId) === 3 && + ids.includes(Number(row.id1)) && + (stream !== 'scope' || Number(row.userId) === input.userId) + assert.equal(before.filter(deleted).length, 3) + assert(before.filter(deleted).every(row => Number(row.present) === 0)) + await killAt({ ...input, operation: stream, phase, request, marker: join(input.directory, stream + '-' + phase) }) + assert.deepEqual( + await k(table).orderBy(order), + phase === 'after-commit' ? before.filter(row => !deleted(row)) : before + ) + if (input.backend === 'sqlite') + assert.equal(await clock(), phase === 'after-commit' ? String(BigInt(beforeClock) + 1n) : beforeClock) + assert.deepEqual(await source(k), beforeSource) + const recovered = await k.transaction(t => collectSnapshotJournalTombstones(t, request)) + assert.equal(recovered.removed, phase === 'after-commit' ? 0 : 3) + assert(recovered.examined <= 3) + await exact(k) + assert.deepEqual(await source(k), beforeSource) + await k('tx_labels') + .where('txLabelId', 1) + .update({ label: 'writer after ' + stream + ' ' + phase }) + await exact(k) + results.push({ + operation: stream, + phase, + atomic: true, + sourcePreserved: true, + recovered: true, + writerProgress: true + }) + }) + }) + return results +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysql.cjs new file mode 100644 index 000000000..18c9c8bd3 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysql.cjs @@ -0,0 +1,201 @@ +const assert = require('node:assert/strict') +const { mkdtemp, rm } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const cuts = require('./snapshotJournalRetentionCuts.cjs') +const { + open, + StorageKnex, + StorageProvider, + seedArchiveClosure, + tables, + exact +} = require('./snapshotJournalMysqlConnection.cjs') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + installSnapshotJournalMysqlGeneration, + completeSnapshotJournalMysqlGeneration +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.js') +const { + copySnapshotJournalBootstrapPage +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalBootstrap.js') +const { + advanceSnapshotJournalFloor, + collectSnapshotJournalReceipts +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js') +const { + collectSnapshotJournalTombstones, + snapshotJournalCollectionQuery +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalCollection.js') +const { snapshotArchiveDatabaseNow } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveSql.js') +const identity = '02' + '11'.repeat(32) +const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } +async function collect(k, epoch, floor, stream) { + let after, + complete = false, + examined = 0, + removed = 0 + function* pages() { + for (let n = 0; n < 100 && !complete; n++) yield n + } + await runInSeries(pages(), async () => { + const input = { epoch, floor, stream, limit: 32, after } + const result = await k.transaction(t => collectSnapshotJournalTombstones(t, input)) + assert(result) + assert(result.examined <= 32) + if (after !== undefined) { + const sql = snapshotJournalCollectionQuery(k, input).toSQL() + const [plan] = await k.raw('EXPLAIN ' + sql.sql, sql.bindings) + assert.equal(plan[0].key, 'PRIMARY') + assert.equal(plan[0].type, 'range') + assert(!String(plan[0].Extra).includes('filesort')) + } + examined += result.examined + removed += result.removed + after = result.after + complete = result.complete + }) + assert(complete, 'bounded collector did not finish in one hundred pages') + return { examined, removed } +} +module.exports = async function main(isolation) { + assert(['READ COMMITTED', 'REPEATABLE READ'].includes(isolation)) + const directory = await mkdtemp(join(tmpdir(), 'ts569-retention-mysql-')) + const k = open(true), + peer = open(true) + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + let first, next, failure + try { + await storage.migrate('native retention fixture', 'synthetic-retention-native') + await storage.makeAvailable() + const { user } = await storage.findOrInsertUser(identity), + { user: other } = await storage.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(storage, user.userId, other.userId) + await installSnapshotJournalMysqlGeneration(k, request.ceiling, request.receiptPolicy) + let finished = false + function* bootstrap() { + for (let n = 0; n < 100 && !finished; n++) yield n + } + await runInSeries(bootstrap(), async () => { + finished = (await copySnapshotJournalBootstrapPage(k, 1000000)).complete + }) + assert(finished) + await completeSnapshotJournalMysqlGeneration(k, request.ceiling, request.receiptPolicy) + const date = new Date('2026-01-01T00:00:00Z') + const labels = Array.from({ length: 600 }, (_, n) => ({ + txLabelId: n + 100, + userId: user.userId, + label: 'native-retention-' + n, + isDeleted: false, + created_at: date, + updated_at: date + })) + await k('tx_labels').insert(labels) + await k.raw('ALTER TABLE snapshot_journal_events AUTO_INCREMENT=9007199254740993') + await peer.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + const old = await peer.transaction() + try { + assert.equal((await old('snapshot_journal_receipts')).length, 0) + first = await storage.openSnapshotJournalSource(identity, request) + assert(BigInt(first.receipt.highWater) > 9007199254740991n) + const started = performance.now() + await assert.rejects(advanceSnapshotJournalFloor(old, String(BigInt(first.receipt.highWater) + 1n))) + assert(performance.now() - started < 2000) + } finally { + await old.rollback() + } + const floor = await k.transaction(t => advanceSnapshotJournalFloor(t, first.receipt.highWater)) + assert.equal(floor.floor, first.receipt.highWater) + const held = await peer.transaction() + try { + await held('snapshot_journal_clock').where('id', 1).forUpdate().first() + const started = performance.now() + await assert.rejects( + k.transaction(t => advanceSnapshotJournalFloor(t, first.receipt.highWater)), + error => error.code === 'ER_LOCK_NOWAIT' + ) + assert(performance.now() - started < 2000) + } finally { + await held.rollback() + } + const pinned = (await first.readPage('certificateFields')).rows + await k('certificate_fields').where({ certificateId: 1, fieldName: 'é' }).delete() + assert.deepEqual((await first.readPage('certificateFields')).rows, pinned) + for (const stream of ['scope', 'physical']) { + const before = await collect(k, first.receiptBinding.epoch, first.receipt.highWater, stream) + assert.equal(before.removed, 0, 'newer tombstones must survive an older floor') + assert(before.examined >= 600) + } + await first.close() + first = undefined + const now = await snapshotArchiveDatabaseNow(k) + await k('snapshot_journal_receipts').update({ expiresAt: now - 1 }) + assert.equal(await k.transaction(t => collectSnapshotJournalReceipts(t)), 1) + next = await storage.openSnapshotJournalSource(identity, request) + await k.transaction(t => advanceSnapshotJournalFloor(t, next.receipt.highWater)) + const beforeSource = {} + await runInSeries(tables, async table => { + beforeSource[table] = await k(table) + }) + const results = {} + await runInSeries(['scope', 'physical'], async stream => { + results[stream] = await collect(k, next.receiptBinding.epoch, next.receipt.highWater, stream) + assert(results[stream].removed > 0) + assert(results[stream].examined >= 600) + }) + await exact(k) + await runInSeries(tables, async table => { + assert.deepEqual(await k(table), beforeSource[table]) + }) + const epoch = next.receiptBinding.epoch + await next.close() + next = undefined + const processLoss = await cuts(k, { backend: 'mysql', isolation, directory, epoch, userId: user.userId }) + console.log( + JSON.stringify({ + fixture: 'journal-retention-native', + isolation, + beyondSafeInteger: true, + liveReceiptPinned: true, + currentReadAfterOldSnapshot: true, + lockRefusal: true, + retainedViewImmutable: true, + newerTombstonePreserved: true, + databaseClockExpiry: true, + primaryKeyRangePlans: true, + bound: 32, + exactLiveMetadata: true, + allThirteenSourceTablesPreserved: true, + results, + processLoss, + limitations: [ + 'internal component fixture', + 'complete mutation and source integration pending', + 'no runtime quotas or registered migration', + 'synthetic MySQL8.4; no production/PXC/failover qualification' + ] + }) + ) + } catch (error) { + failure = { error } + } + const closed = await Promise.allSettled([ + first?.close(), + next?.close(), + storage.destroy(), + k.destroy(), + peer.destroy() + ]) + const errors = closed.filter(result => result.status === 'rejected').map(result => result.reason) + try { + await rm(directory, { recursive: true, force: true }) + } catch (error) { + errors.push(error) + } + if (errors.length) + throw new AggregateError( + [...(failure === undefined ? [] : [failure.error]), ...errors], + 'Native retention fixture cleanup failed' + ) + if (failure !== undefined) throw failure.error +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysqlRc.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysqlRc.cjs new file mode 100644 index 000000000..a0377ff37 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysqlRc.cjs @@ -0,0 +1,4 @@ +require('./snapshotJournalRetentionMysql.cjs')('READ COMMITTED').catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysqlRr.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysqlRr.cjs new file mode 100644 index 000000000..6cc9e5de4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysqlRr.cjs @@ -0,0 +1,4 @@ +require('./snapshotJournalRetentionMysql.cjs')('REPEATABLE READ').catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionProcessLoss.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionProcessLoss.cjs new file mode 100644 index 000000000..977e08f7b --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionProcessLoss.cjs @@ -0,0 +1,67 @@ +const assert = require('node:assert/strict') +const { fork } = require('node:child_process') +const { writeFileSync } = require('node:fs') +const { open } = require('node:fs/promises') +const { join } = require('node:path') + +// These hooks belong only to the disposable fixture's own native pool. +function inject(k, operation, phase) { + assert(['floor', 'scope', 'physical'].includes(operation)) + assert(['after-first-delete', 'before-commit', 'after-commit'].includes(phase)) + assert(operation !== 'floor' || phase !== 'after-first-delete') + const prefix = + operation === 'floor' ? 'update `snapshot_journal_retention`' : 'delete from `snapshot_journal_' + operation + '`' + let changed = false + const park = () => { + writeFileSync(4, phase) + process.kill(process.pid, 'SIGKILL') + } + k.on('query', q => { + if (q.sql.startsWith(prefix)) changed = true + if (changed && q.sql === 'COMMIT;' && phase === 'before-commit') park() + }) + k.on('query-response', (_response, q) => { + if (q.sql.startsWith(prefix) && phase === 'after-first-delete') park() + }) + const transaction = k.client.transaction + k.client.transaction = function (...parameters) { + const trx = transaction.apply(this, parameters) + const commit = trx.commit + trx.commit = async function (...values) { + const result = await commit.apply(this, values) + if (changed && phase === 'after-commit') { + await this.transactor.executionPromise + park() + } + return result + } + return trx + } +} +async function killAt(input) { + const { marker, ...childInput } = input + const output = await open(marker, 'wx+', 0o600) + const child = fork(join(__dirname, 'snapshotJournalRetentionChild.cjs'), [JSON.stringify(childInput)], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc', output.fd] + }) + let stderr = '' + child.stderr.on('data', data => { + stderr += data + }) + const timer = setTimeout(() => child.kill('SIGKILL'), 20000) + try { + const result = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + assert.equal(result.signal, 'SIGKILL', stderr) + const bytes = Buffer.alloc(64) + const { bytesRead } = await output.read(bytes, 0, bytes.length, 0) + assert.equal(bytes.subarray(0, bytesRead).toString('utf8'), input.phase) + } finally { + clearTimeout(timer) + await output.close() + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + } +} +module.exports = { inject, killAt } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionSqlite.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionSqlite.cjs new file mode 100644 index 000000000..90376da6c --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionSqlite.cjs @@ -0,0 +1,209 @@ +const assert = require('node:assert/strict') +const { mkdtemp, rm } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { + knex, + StorageKnex, + StorageProvider, + seedArchiveClosure, + tables, + exact +} = require('./snapshotJournalNativeFixture.cjs') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + installSnapshotJournalSqliteGeneration, + completeSnapshotJournalSqliteGeneration +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.js') +const { + copySnapshotJournalBootstrapPage +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalBootstrap.js') +const { + advanceSnapshotJournalFloor, + collectSnapshotJournalReceipts +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js') +const { + collectSnapshotJournalTombstones, + snapshotJournalCollectionQuery +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalCollection.js') +const { snapshotArchiveDatabaseNow } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveSql.js') +const cuts = require('./snapshotJournalRetentionCuts.cjs') +const identity = '02' + '11'.repeat(32) +const request = { ceiling: '9223372036854775807', receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } } +const open = filename => + knex({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true, pool: { min: 1, max: 1 } }) +async function collect(k, epoch, floor, stream) { + let after, + complete = false, + examined = 0, + removed = 0 + function* pages() { + for (let n = 0; n < 100 && !complete; n++) yield n + } + await runInSeries(pages(), async () => { + const input = { epoch, floor, stream, limit: 32, after }, + result = await k.transaction(t => collectSnapshotJournalTombstones(t, input)) + assert(result) + assert(result.examined <= 32) + if (after !== undefined) { + const sql = snapshotJournalCollectionQuery(k, input).toSQL(), + plan = await k.raw('EXPLAIN QUERY PLAN ' + sql.sql, sql.bindings) + assert(plan.some(row => row.detail.includes('SEARCH j USING INDEX sqlite_autoindex_snapshot_journal_'))) + assert(plan.every(row => !row.detail.includes('TEMP B-TREE'))) + } + examined += result.examined + removed += result.removed + after = result.after + complete = result.complete + }) + assert(complete) + return { examined, removed } +} +async function main() { + const directory = await mkdtemp(join(tmpdir(), 'ts569-retention-sqlite-')), + filename = join(directory, 'wallet.sqlite'), + k = open(filename), + peer = open(filename) + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + let first, next, failure + try { + await k.raw('PRAGMA journal_mode=WAL') + await k.raw('PRAGMA busy_timeout=0') + await peer.raw('PRAGMA busy_timeout=0') + await storage.migrate('native retention WAL fixture', 'synthetic-retention-native') + await storage.makeAvailable() + const { user } = await storage.findOrInsertUser(identity), + { user: other } = await storage.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(storage, user.userId, other.userId) + await installSnapshotJournalSqliteGeneration(k, request.ceiling, request.receiptPolicy) + let finished = false + function* bootstrap() { + for (let n = 0; n < 100 && !finished; n++) yield n + } + await runInSeries(bootstrap(), async () => { + finished = (await copySnapshotJournalBootstrapPage(k, 1000000)).complete + }) + assert(finished) + await completeSnapshotJournalSqliteGeneration(k, request.receiptPolicy) + const date = '2026-01-01T00:00:00.000Z' + await runInSeries( + Array.from({ length: 6 }, (_, n) => n), + async n => { + await k('tx_labels').insert( + Array.from({ length: 100 }, (_, i) => ({ + txLabelId: 100 + n * 100 + i, + userId: user.userId, + label: 'native-retention-' + (n * 100 + i), + isDeleted: false, + created_at: date, + updated_at: date + })) + ) + } + ) + await k('snapshot_journal_clock').where('id', 1).update({ revision: '9007199254740993' }) + first = await storage.openSnapshotJournalSource(identity, request) + assert(BigInt(first.receipt.highWater) > 9007199254740991n) + await assert.rejects( + k.transaction(t => advanceSnapshotJournalFloor(t, String(BigInt(first.receipt.highWater) + 1n))) + ) + const floor = await k.transaction(t => advanceSnapshotJournalFloor(t, first.receipt.highWater)) + assert.equal(floor.floor, first.receipt.highWater) + const held = await peer.transaction() + try { + await held('snapshot_journal_clock') + .where('id', 1) + .update({ revision: held.ref('revision') }) + const started = performance.now() + await assert.rejects( + k.transaction(t => advanceSnapshotJournalFloor(t, first.receipt.highWater)), + error => error.code === 'SQLITE_BUSY' + ) + assert(performance.now() - started < 2000) + } finally { + await held.rollback() + } + const pinned = await first.readPage('certificateFields') + await k('certificate_fields').where({ certificateId: 1, fieldName: 'é' }).delete() + assert.deepEqual(await first.readPage('certificateFields'), pinned) + const before = await collect(k, first.receiptBinding.epoch, floor.floor, 'scope') + assert(before.examined >= 600) + assert.equal(before.removed, 0) + const beforePhysical = await collect(k, first.receiptBinding.epoch, floor.floor, 'physical') + assert(beforePhysical.examined >= 600) + assert.equal(beforePhysical.removed, 0) + await first.close() + first = undefined + await k('snapshot_journal_receipts').update({ expiresAt: (await snapshotArchiveDatabaseNow(k)) - 1 }) + assert.equal(await k.transaction(t => collectSnapshotJournalReceipts(t)), 1) + next = await storage.openSnapshotJournalSource(identity, request) + const advanced = await k.transaction(t => advanceSnapshotJournalFloor(t, next.receipt.highWater)), + beforeSource = {} + await runInSeries(tables, async table => { + beforeSource[table] = await k(table) + }) + const results = { + scope: await collect(k, next.receiptBinding.epoch, advanced.floor, 'scope'), + physical: await collect(k, next.receiptBinding.epoch, advanced.floor, 'physical') + } + assert(results.scope.removed > 0) + assert(results.physical.removed > 0) + await exact(k) + await runInSeries(tables, async table => { + assert.deepEqual(await k(table), beforeSource[table]) + }) + const epoch = next.receiptBinding.epoch + await next.close() + next = undefined + const processLoss = await cuts(k, { backend: 'sqlite', filename, directory, epoch, userId: user.userId }) + assert.deepEqual(await k.raw('PRAGMA foreign_key_check'), []) + console.log( + JSON.stringify({ + fixture: 'journal-retention-WAL', + beyondSafeInteger: true, + liveReceiptPinned: true, + lockRefusal: true, + retainedViewImmutable: true, + databaseClockExpiry: true, + primaryKeyRangePlans: true, + bound: 32, + allThirteenSourceTablesPreserved: true, + results, + processLoss, + limitations: [ + 'internal component fixture', + 'complete mutation and source integration pending', + 'no runtime quotas or registered migration', + 'no production or filesystem power-loss qualification' + ] + }) + ) + } catch (error) { + failure = { error } + } + const closed = await Promise.allSettled([ + first?.close(), + next?.close(), + storage.destroy(), + k.destroy(), + peer.destroy() + ]), + errors = closed.filter(result => result.status === 'rejected').map(result => result.reason) + try { + await rm(directory, { recursive: true, force: true }) + } catch (error) { + errors.push(error) + } + if (errors.length) + throw new AggregateError( + [...(failure === undefined ? [] : [failure.error]), ...errors], + 'WAL retention fixture cleanup failed' + ) + if (failure !== undefined) throw failure.error +} +module.exports = main +if (require.main?.filename === __filename) + main().catch(error => { + console.error(error) + process.exitCode = 1 + }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs index b76c412f6..79022ccdb 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs @@ -191,6 +191,7 @@ async function main() { } ) await require('./snapshotJournalCaptureSqlite.cjs')() + await require('./snapshotJournalRetentionSqlite.cjs')() console.log( JSON.stringify({ status: 'SQLite journal generation native WAL process-loss checks', @@ -199,7 +200,7 @@ async function main() { limitations: [ 'not yet a registered forward migration', 'no MySQL implicit-DDL lifecycle qualification', - 'atomic floor/quota/receiver and platform-scale acceptance remain open' + 'provider floor ownership/quota/receiver and platform-scale acceptance remain open' ] }) ) diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index b83c3aa73..2cc964eed 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -62,7 +62,8 @@ const plans = new Map([ ['src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts', 'capture-fence'], ['src/storage/snapshot/journal/SnapshotJournalConnections.ts', 'connections'], ['src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', 'capture-backend'], - ['src/storage/snapshot/journal/SnapshotJournalCapture.ts', 'capture'] + ['src/storage/snapshot/journal/SnapshotJournalCapture.ts', 'capture'], + ['src/storage/snapshot/journal/SnapshotJournalCollection.ts', 'collection'] ]) } ], diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 50dea38b0..e28d1b81a 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -298,7 +298,8 @@ for (const [id, expected, fallback] of [ 'capture-fence', 'connections', 'capture-backend', - 'capture' + 'capture', + 'collection' ], 'revision' ], diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index ca67918c5..7f568c2cf 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -261,7 +261,8 @@ test('journal mutation registration retains its complete source, canonical tests 'src/storage/snapshot/journal/SnapshotJournalCaptureFence.ts', 'src/storage/snapshot/journal/SnapshotJournalConnections.ts', 'src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', - 'src/storage/snapshot/journal/SnapshotJournalCapture.ts' + 'src/storage/snapshot/journal/SnapshotJournalCapture.ts', + 'src/storage/snapshot/journal/SnapshotJournalCollection.ts' ]) assert.deepEqual(target.additionalInputs, [ 'test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json', @@ -281,6 +282,13 @@ test('journal mutation registration retains its complete source, canonical tests 'test/storage/snapshotJournalCaptureMysqlChild.cjs', 'test/storage/snapshotJournalCaptureProcessLoss.cjs', 'test/storage/snapshotJournalCaptureSqlite.cjs', + 'test/storage/snapshotJournalRetentionChild.cjs', + 'test/storage/snapshotJournalRetentionCuts.cjs', + 'test/storage/snapshotJournalRetentionMysql.cjs', + 'test/storage/snapshotJournalRetentionMysqlRc.cjs', + 'test/storage/snapshotJournalRetentionMysqlRr.cjs', + 'test/storage/snapshotJournalRetentionProcessLoss.cjs', + 'test/storage/snapshotJournalRetentionSqlite.cjs', 'test/storage/snapshotJournalSqliteCrash.cjs', 'test/storage/runSnapshotJournalMysql.cjs', 'test/storage/snapshotArchiveDocker.cjs' diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index b6c1846a8..b9a0145bc 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -268,7 +268,7 @@ pinned complete generation, profile and schema, commits an exact receipt before publication, and verifies closure after releasing the writer barrier. WAL and MySQL native fixtures cover process loss before commit, after durable commit and after publication. These component proofs advance S2; they do not complete it. -Atomic continuity floors, bounded tombstone collection, runtime quotas, registered +Provider floor/collection integration, runtime quotas, registered journal migration/recovery, generation-aware delta payload pages and receiver integration remain required. The other acceptance rows remain open. @@ -283,3 +283,27 @@ YAML dependency. Disposable process-cut children receive an exclusive inherited marker descriptor and cannot choose a filesystem write path through CLI input. Local source controls and the native capture cuts qualify this remediation; complete exact-head hosted success is still required before any ready claim. + +## Internal retention floor and collection checkpoint + +Floor advancement now reserves the global writer clock before current retention +and receipt locks and refuses a backwards, future or live-prefix-crossing floor. +Database-clock expiry decides whether a receipt still pins continuity; expired +records stay charged until bounded receipt deletion commits. Collection examines +at most 256 complete primary-key rows, including live/newer entries, validates the +whole page, and deletes only absent metadata at or before the fixed bound floor. +Its cursor binds epoch, floor, stream and exact composite key. + +Native WAL, MySQL RC and RR fixtures prove exact large revisions, active-prefix +pins, current reads through an older transaction snapshot, nonwaiting conflict +refusal, PRIMARY seek plans, retained-view immutability and all thirteen source +tables preserved. Twenty-four real process cuts cover partial deletion and floor +or collection commit with rollback/lost-acknowledgement recovery. Sixty dedicated +tests include 300 independent seeded floor ledgers. The complete journal target +now owns nineteen whole modules in seventeen parts; no campaign threshold, +source/test union or final qualification gate is reduced. + +Provider maintenance integration, runtime quotas and registered forward lifecycle +and recovery remain required within S2. No public reader advertisement or +migration registration is introduced by this checkpoint. Every acceptance row +remains open until its complete implementation and end-to-end evidence exist. From 9df1f419563fa49fd1c433d2716131ef1e30c57b Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 15:37:43 -0700 Subject: [PATCH 094/127] Keep native retention collection passes sequential through the serial helper --- .../test/storage/snapshotJournalRetentionMysql.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysql.cjs index 18c9c8bd3..95890c5b8 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalRetentionMysql.cjs @@ -121,11 +121,11 @@ module.exports = async function main(isolation) { const pinned = (await first.readPage('certificateFields')).rows await k('certificate_fields').where({ certificateId: 1, fieldName: 'é' }).delete() assert.deepEqual((await first.readPage('certificateFields')).rows, pinned) - for (const stream of ['scope', 'physical']) { + await runInSeries(['scope', 'physical'], async stream => { const before = await collect(k, first.receiptBinding.epoch, first.receipt.highWater, stream) assert.equal(before.removed, 0, 'newer tombstones must survive an older floor') assert(before.examined >= 600) - } + }) await first.close() first = undefined const now = await snapshotArchiveDatabaseNow(k) From 6da089b76269a97c6f41db31a4cd2df7afe1d73a Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 15:49:12 -0700 Subject: [PATCH 095/127] test(ci): preserve separate PR and full-campaign concurrency limits --- scripts/ci-orchestration.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 0245b3dbb..84c5dfb86 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -177,9 +177,9 @@ test('CI bounds every job and allocates no runner for an empty infrastructure ma }) test('wallet mutation allowances preserve other limits and complete campaign execution', () => { - for (const [path, defaultMinutes] of [ - [CI_PATH, 45], - [MUTATION_PATH, 45] + for (const [path, defaultMinutes, maxParallel] of [ + [CI_PATH, 45, 6], + [MUTATION_PATH, 45, 20] ]) { const job = workflowJobBlocks(readFileSync(path, 'utf8')).find( job => job.name === 'mutation-tests' @@ -187,7 +187,7 @@ test('wallet mutation allowances preserve other limits and complete campaign exe assert.ok(job, path) assertWalletMutationTimeout(job, defaultMinutes) assert.match(job.source, /^ fail-fast: false$/m) - assert.match(job.source, /^ max-parallel: 6$/m) + assert.match(job.source, new RegExp(`^ max-parallel: ${maxParallel}$`, 'm')) assert.match( job.source, /^ run: node scripts\/mutation-testing\.mjs --target "\$\{\{ matrix\.target \}\}" --partition "\$\{\{ matrix\.partition \}\}"$/m From be0be625d3b79b6fdd01244cfaee4f930f17ab12 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 17:11:35 -0700 Subject: [PATCH 096/127] feat(wallet): own bounded journal maintenance and native cleanup --- docs/guides/wallet-sync-reliability.md | 33 +- docs/reference/package-api-migrations.md | 74 ++-- governance/mutation-testing/targets.mjs | 16 +- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/README.md | 7 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 73 +++- .../journal/SnapshotJournalCollection.ts | 30 +- .../SnapshotJournalMaintenance.test.ts | 403 ++++++++++++++++++ .../journal/SnapshotJournalMaintenance.ts | 195 +++++++++ .../SnapshotJournalMaintenanceFence.test.ts | 245 +++++++++++ .../SnapshotJournalMaintenanceFence.ts | 55 +++ .../SnapshotJournalMaintenanceTask.test.ts | 334 +++++++++++++++ .../journal/SnapshotJournalMaintenanceTask.ts | 91 ++++ .../test/storage/runSnapshotJournalMysql.cjs | 14 +- .../snapshotJournalMaintenanceChild.cjs | 56 +++ .../snapshotJournalMaintenanceCuts.cjs | 140 ++++++ .../snapshotJournalMaintenanceFixture.cjs | 301 +++++++++++++ .../snapshotJournalMaintenanceMysqlRc.cjs | 4 + .../snapshotJournalMaintenanceMysqlRr.cjs | 4 + .../snapshotJournalMaintenanceProcessLoss.cjs | 67 +++ .../storage/snapshotJournalMaintenanceWal.cjs | 4 + .../storage/snapshotJournalSqliteCrash.cjs | 3 +- scripts/mutation-partitions.mjs | 5 +- scripts/mutation-partitions.test.mjs | 5 +- scripts/mutation-testing.test.mjs | 32 +- specs/wallet/sync-portability-program.md | 26 ++ 26 files changed, 2153 insertions(+), 68 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.ts create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceChild.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceCuts.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceFixture.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceMysqlRc.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceMysqlRr.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceProcessLoss.cjs create mode 100644 packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceWal.cjs diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index e0823971e..b829a85bd 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -1128,25 +1128,40 @@ collection commit and committed lost acknowledgements, then independently check metadata/source preservation and writer progress. These remain synthetic engine proofs, with production/PXC and power-loss acceptance separate. -These primitives require the caller to validate complete owned DDL/source and -exclude migration before a fresh transaction. Provider-owned maintenance and -registered retention recovery remain unfinished, as does generation-aware -delta-page/receiver integration. A durable prefix proof does not reopen a killed +The internal `StorageKnex.maintainSnapshotJournal` controller owns each bounded +floor or collection transaction. It shares admission with retained capture, +validates the complete owned generation, ceiling and receipt policy, and excludes +the configured Knex migration owner before maintenance. SQLite reserves its WAL +writer before generation reads; MySQL locks only the configured migration-owner +row during schema validation, allowing foreground wallet writes until the short +clock/retention operation. Raw operator DDL remains outside this owner contract. + +Requests detach their cursor keys before configuration work. Cancellation or a +1–30,000 millisecond lifetime rejects the result promptly while physical drain +continues to own provider admission. Destruction fences admission synchronously; +transaction or pool cleanup failure remains observable and prevents new retained +work. A cancellation after commit can lose its result but cannot undo that commit; +recovery reads the durable floor/metadata before repeating a bounded operation. +WAL and actual RC/RR owned pools exercise eight process-loss cuts each, plus +provider cancellation, shutdown and physical native cleanup. + +Registered generation/retention recovery and generation-aware delta-page/receiver +integration remain unfinished. A durable prefix proof does not reopen a killed database read transaction. This foundation adds no registered migration, public capability or reader advertisement. Its event-window invalidation is not a complete retention or -resource policy. Runtime quotas and provider continuity-floor ownership, +resource policy. Runtime quotas and registered generation lifecycle, generation-aware receiver/primary integration, and remaining remote/IndexedDB, streaming and staged-import acceptance remain unfinished. Do not infer full incremental continuity or completed issue #544 from these helpers. -The wallet-snapshot-journal mutation target owns all nineteen complete source -modules in seventeen execution parts, including the whole receipt, collection, capture, native -backend, connection ownership and barrier modules. Every part retains the complete canonical +The wallet-snapshot-journal mutation target owns all twenty-two complete source +modules in twenty execution parts, including the whole receipt, collection, maintenance, capture, native +backend, connection ownership and migration/barrier modules. Every part retains the complete canonical journal tests and fixtures, including one governed property entry for exact revision/page, generated source-observer and committed receipt-state schedules. A minimum score of 90%, zero -uncovered/invalid mutants, 300 cases with seed 3242026, four workers, runner reuse 8 and 90-minute +uncovered/invalid/unexecuted mutants, 300 cases with seed 3242026, four workers, runner reuse 8 and 90-minute bounds remain in force. Native ownership, client/server process-loss and broader platform/performance evidence are separate required validation; source helpers alone do not establish deployment, replication, PXC or power-loss readiness. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index d05451054..fb6318842 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. Internal floor/collection primitives require a validated complete owned generation and migration exclusion before fresh short transactions. Provider maintenance, runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. Internal floor/collection primitives require a validated complete owned generation and migration exclusion before fresh short transactions. Provider maintenance, runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 16fe22314..5688b0979 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -178,7 +178,8 @@ function snapshotSyncMutationTargets(repositoryRoot) { '/src/storage/sync/syncFailure.test.ts', '/src/storage/sync/syncSession.test.ts', '/src/storage/sync/syncCheckpoint.test.ts', - '/src/utility/__tests__/runInSeries.test.ts' + '/src/utility/__tests__/runInSeries.test.ts', + '/src/storage/snapshot/journal/*.test.ts' ], { config: { @@ -496,6 +497,7 @@ export function buildMutationTargets(repositoryRoot) { '/src/storage/snapshot/*.test.ts', '/src/storage/snapshot/journal/SnapshotJournalCapture*.test.ts', '/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts', + '/src/storage/snapshot/journal/SnapshotJournalMaintenance*.test.ts', '/src/storage/__test/StorageKnexMigrationFailure.security.test.ts' ], { @@ -537,6 +539,13 @@ export function buildMutationTargets(repositoryRoot) { 'test/storage/snapshotJournalRetentionMysqlRr.cjs', 'test/storage/snapshotJournalRetentionProcessLoss.cjs', 'test/storage/snapshotJournalRetentionSqlite.cjs', + 'test/storage/snapshotJournalMaintenanceFixture.cjs', + 'test/storage/snapshotJournalMaintenanceCuts.cjs', + 'test/storage/snapshotJournalMaintenanceChild.cjs', + 'test/storage/snapshotJournalMaintenanceProcessLoss.cjs', + 'test/storage/snapshotJournalMaintenanceWal.cjs', + 'test/storage/snapshotJournalMaintenanceMysqlRc.cjs', + 'test/storage/snapshotJournalMaintenanceMysqlRr.cjs', 'test/storage/snapshotJournalSqliteCrash.cjs', 'test/storage/runSnapshotJournalMysql.cjs', 'test/storage/snapshotArchiveDocker.cjs' @@ -562,7 +571,10 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/snapshot/journal/SnapshotJournalConnections.ts', 'src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', 'src/storage/snapshot/journal/SnapshotJournalCapture.ts', - 'src/storage/snapshot/journal/SnapshotJournalCollection.ts' + 'src/storage/snapshot/journal/SnapshotJournalCollection.ts', + 'src/storage/snapshot/journal/SnapshotJournalMaintenance.ts', + 'src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.ts', + 'src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.ts' ], ...jestTarget('jest.config.cjs', ['/src/storage/snapshot/journal/*.test.ts'], { maxTestRunnerReuse: 8, diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 59ecba1db..266d97fd5 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Continuity floors, quotas, delta paging and receiver integration remain pending. Internal floor/collection primitives require a validated complete owned generation and migration exclusion before fresh short transactions. Provider maintenance, runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced." + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 06181369e..89655690a 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -76,8 +76,11 @@ the global writer clock before current receipt locks and cannot pass a live receipt's prefix. Tombstone collection examines at most 256 primary-key rows, including live and newer records, and binds its cursor to one epoch, floor and stream. Native WAL/RC/RR checks cover twenty-four real process-loss boundaries. -Callers must validate the complete owned generation and exclude migrations before -these short transactions; automatic provider maintenance remains unfinished. +The internal `maintainSnapshotJournal` provider controller validates the complete +owned generation and excludes the configured migration owner before each short +floor or collection transaction. It shares source admission through cancellation, +rollback and physical cleanup; failed cleanup fences further retained admission. +Automatic scheduling remains unfinished. These helpers do not register a migration or advertise incremental synchronization. Runtime quotas, registered recovery, generation-aware delta pages and receiver/primary integration and the remaining issue #544 acceptance work are diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 1dae5a59c..b2ce3b4f1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,3 +1,12 @@ +import { + maintainSnapshotJournal as startSnapshotJournalMaintenance, + type SnapshotJournalMaintenanceRequest, + type SnapshotJournalMaintenanceResult +} from './snapshot/journal/SnapshotJournalMaintenance' +import type { + SnapshotJournalMaintenanceTask, + SnapshotJournalMaintenanceOptions +} from './snapshot/journal/SnapshotJournalMaintenanceTask' import { retainSnapshotJournalCapture, type SnapshotJournalCaptureRequest, @@ -149,6 +158,8 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide private snapshotSyncBusy = false private snapshotJournalCapture?: SnapshotJournalCaptureLifetime private snapshotJournalCaptureFailure?: SnapshotJournalConnectionCleanupError + private snapshotJournalMaintenance?: SnapshotJournalMaintenanceTask + private snapshotJournalMaintenanceFailure?: SnapshotJournalConnectionCleanupError private snapshotArchiveRecovery?: Promise private guardedSnapshotFailure?: { error: unknown } private retainedReadSnapshot?: RetainedReadSnapshotLifetime @@ -286,9 +297,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide try { await this.snapshotJournalCapture?.close() } finally { - await this.snapshotSyncOpening?.catch(() => undefined) - await this.snapshotSyncSource?.destroy() - await this.retainedReadSnapshot?.close() + try { + await this.snapshotJournalMaintenance?.close() + } finally { + await this.snapshotSyncOpening?.catch(() => undefined) + await this.snapshotSyncSource?.destroy() + await this.retainedReadSnapshot?.close() + } } } } @@ -436,6 +451,54 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return await lifetime.opened } + /** One internal global floor/page operation; shares source admission until native cleanup drains. */ + async maintainSnapshotJournal( + request: SnapshotJournalMaintenanceRequest, + options: SnapshotJournalMaintenanceOptions = {} + ): Promise { + if (this.retainedReadSnapshotsStopped) + throw new WERR_INVALID_OPERATION('Snapshot journal maintenance is unavailable after destruction begins') + if (this.snapshotSyncBusy) + throw new WERR_INVALID_OPERATION('This provider already has a snapshot source or maintenance opening or active') + this.snapshotSyncBusy = true + let lifetime: SnapshotJournalMaintenanceTask + try { + lifetime = startSnapshotJournalMaintenance( + async () => { + if (this.retainedReadSnapshotsStopped) + throw new WERR_INVALID_OPERATION('Snapshot journal maintenance is unavailable after destruction begins') + return await this.concurrentSnapshotReaderConfig() + }, + request, + options + ) + } catch (error) { + this.snapshotSyncBusy = false + throw error + } + this.snapshotJournalMaintenance = lifetime + const release = (error?: unknown): void => { + if (error instanceof SnapshotJournalConnectionCleanupError) { + this.snapshotJournalMaintenanceFailure = error + this.retainedReadSnapshotsStopped = true + return + } + if (this.snapshotJournalMaintenance === lifetime) { + this.snapshotJournalMaintenance = undefined + this.snapshotSyncBusy = false + } + } + void lifetime.closed.then(() => release(), release) + return await lifetime.result + } + + /** Drain an already-stopping maintenance owner; a failed native cleanup remains observable. */ + awaitSnapshotJournalMaintenanceCleanup(): Promise { + if (this.snapshotJournalMaintenanceFailure !== undefined) + return Promise.reject(this.snapshotJournalMaintenanceFailure) + return this.snapshotJournalMaintenance?.closed ?? Promise.resolve() + } + /** Drain an already-stopping capture without admitting another source. */ awaitSnapshotJournalCaptureCleanup(): Promise { if (this.snapshotJournalCaptureFailure !== undefined) return Promise.reject(this.snapshotJournalCaptureFailure) @@ -2116,7 +2179,8 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide // cleanup failure remains observable after the pool destruction below. if ( (this.guardedSnapshotFailure === undefined || this.guardedSnapshotFailure.error !== error) && - this.snapshotJournalCaptureFailure !== error + this.snapshotJournalCaptureFailure !== error && + this.snapshotJournalMaintenanceFailure !== error ) throw error } finally { @@ -2133,6 +2197,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide const failure = this.guardedSnapshotFailure?.error if (failure instanceof SnapshotArchiveSourceCleanupError) throw failure if (this.snapshotJournalCaptureFailure !== undefined) throw this.snapshotJournalCaptureFailure + if (this.snapshotJournalMaintenanceFailure !== undefined) throw this.snapshotJournalMaintenanceFailure } override async migrate(storageName: string, storageIdentityKey: string): Promise { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.ts index 3b7b2683d..e82dceeaa 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCollection.ts @@ -62,7 +62,9 @@ function key(value: SnapshotJournalCollectionKey, stream: 'scope' | 'physical'): else if (value.userId !== undefined) return invalid() return result } -function detached(value: SnapshotJournalCollectionRequest): SnapshotJournalCollectionRequest { +export function snapshotJournalCollectionRequest( + value: SnapshotJournalCollectionRequest +): SnapshotJournalCollectionRequest { if (!object(value)) return invalid() const { epoch, stream } = value if (typeof epoch !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(epoch)) @@ -78,7 +80,12 @@ function detached(value: SnapshotJournalCollectionRequest): SnapshotJournalColle if (after !== undefined) { if (!object(after) || after.epoch !== epoch || after.floor !== result.floor || after.stream !== stream) return invalid() - result.after = { epoch, stream, floor: result.floor, key: key(after.key, stream) } + result.after = { + epoch, + stream, + floor: result.floor, + key: key(after.key, stream) + } } return result } @@ -124,7 +131,7 @@ function seek(query: Knex.QueryBuilder, k: Knex, request: SnapshotJournalCollect } /** Primary-key scan bounds examined metadata, including live and newer rows. */ export function snapshotJournalCollectionQuery(k: Knex, input: SnapshotJournalCollectionRequest): Knex.QueryBuilder { - const request = detached(input), + const request = snapshotJournalCollectionRequest(input), sqlite = local(k), table = 'snapshot_journal_' + request.stream const hint = sqlite ? '?? AS ?? INDEXED BY ??' : '?? AS ?? FORCE INDEX (??)' @@ -186,7 +193,11 @@ function metadata(row: Record, stream: 'scope' | 'physical', sq const generation = snapshotJournalRevision(row.generationText) if (generation === '0' || compareSnapshotJournalRevisions(generation, revision) > 0) return invalid() } - return { key: position, revision, present: row.present === 1 || row.present === true } + return { + key: position, + revision, + present: row.present === 1 || row.present === true + } } async function generation(k: Knex, epoch: string): Promise { const query = k('snapshot_journal_generation') @@ -235,7 +246,7 @@ export async function collectSnapshotJournalTombstones( } | undefined > { - const request = detached(input) + const request = snapshotJournalCollectionRequest(input) if (!k.isTransaction) return invalid() const highWater = await reserveSnapshotJournalCaptureFence(k) if (highWater === undefined) return undefined @@ -269,6 +280,13 @@ export async function collectSnapshotJournalTombstones( complete: rows.length < request.limit, ...(previous === undefined ? {} - : { after: { epoch: request.epoch, stream: request.stream, floor: request.floor, key: previous } }) + : { + after: { + epoch: request.epoch, + stream: request.stream, + floor: request.floor, + key: previous + } + }) } } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts new file mode 100644 index 000000000..6e4d5e250 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts @@ -0,0 +1,403 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { StorageKnex } from '../../StorageKnex' +import { StorageProvider } from '../../StorageProvider' +import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' +import { maintainSnapshotJournal, type SnapshotJournalMaintenanceRequest } from './SnapshotJournalMaintenance' +import { snapshotJournalRevision } from './SnapshotJournalRevision' +import * as Sqlite from './SnapshotJournalSqliteGeneration' +import * as Mysql from './SnapshotJournalMysqlGeneration' +import * as Backend from './SnapshotJournalCaptureBackend' +import * as Connections from './SnapshotJournalConnections' +import * as Fence from './SnapshotJournalMaintenanceFence' +import * as Receipts from './SnapshotJournalReceipt' +import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' + +const identity = '02' + '11'.repeat(32), + ceiling = snapshotJournalRevision('1000000') +const receiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 600000 } +const epoch = '00000000-0000-4000-8000-000000000000' +const input = (id = epoch): SnapshotJournalMaintenanceRequest => ({ + epoch: id, + ceiling, + receiptPolicy, + operation: { kind: 'floor', floor: snapshotJournalRevision('0') } +}) +function gate() { + let resolve!: () => void + const promise = new Promise(yes => { + resolve = yes + }) + return { promise, resolve } +} +async function fixture(complete = true) { + const directory = await mkdtemp(join(tmpdir(), 'ts569-maintenance-')) + const k = knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: k }) + try { + await storage.migrate('owned maintenance', 'synthetic-maintenance') + await storage.makeAvailable() + await k.raw('PRAGMA journal_mode=WAL') + const { user } = await storage.findOrInsertUser(identity), + { user: other } = await storage.findOrInsertUser('03' + '22'.repeat(32)) + await seedArchiveClosure(storage, user.userId, other.userId) + await Sqlite.installSnapshotJournalSqliteGeneration(k, ceiling, receiptPolicy) + let state = await Sqlite.readSnapshotJournalSqliteGeneration(k, receiptPolicy) + if (complete) { + let finished = false + for (let n = 0; n < 80 && !finished; n++) finished = (await copySnapshotJournalBootstrapPage(k, 1000000)).complete + expect(finished).toBe(true) + state = await Sqlite.completeSnapshotJournalSqliteGeneration(k, receiptPolicy) + } + return { + k, + storage, + request: input(state.epoch), + async close() { + await storage.destroy() + await rm(directory, { recursive: true, force: true }) + } + } + } catch (error) { + await storage.destroy() + await rm(directory, { recursive: true, force: true }) + throw error + } +} +afterEach(() => jest.restoreAllMocks()) + +test('the provider uses owned WAL transactions for floor and bounded collection', async () => { + const f = await fixture() + try { + const result = await f.storage.maintainSnapshotJournal(f.request) + expect(result).toMatchObject({ kind: 'floor', value: { floor: '0' } }) + await f.storage.awaitSnapshotJournalMaintenanceCleanup() + const page = await f.storage.maintainSnapshotJournal({ + ...f.request, + operation: { + kind: 'collect', + page: { epoch: f.request.epoch, floor: snapshotJournalRevision('0'), stream: 'scope', limit: 2 } + } + }) + expect(page).toMatchObject({ kind: 'collect', value: { examined: 2, removed: 0, complete: false } }) + expect(await f.k('snapshot_journal_receipts')).toHaveLength(0) + } finally { + await f.close() + } +}) +test('request binding is validated before native configuration or allocation', () => { + const resolve = jest.fn, []>() + for (const value of [ + { ...input(), epoch: 'bad' }, + { ...input(), epoch: 'prefix' + epoch }, + { ...input(), epoch: epoch + 'suffix' }, + { ...input(), epoch: { toString: () => epoch } }, + { ...input(), ceiling: '0' }, + { ...input(), operation: { kind: 'bad' } }, + { + ...input(), + operation: { + kind: 'bad', + page: { epoch, floor: '0', stream: 'scope', limit: 2 } + } + }, + { + ...input(), + operation: { + kind: 'collect', + page: { epoch: '10000000-0000-4000-8000-000000000000', floor: '0', stream: 'scope', limit: 2 } + } + } + ]) + expect(() => maintainSnapshotJournal(resolve, value as SnapshotJournalMaintenanceRequest)).toThrow() + expect(resolve).not.toHaveBeenCalled() +}) +test('request fields are detached before asynchronous configuration work', async () => { + const f = await fixture(), + entered = gate(), + release = gate() + try { + const original = f.request + const task = maintainSnapshotJournal(async () => { + entered.resolve() + await release.promise + return f.k.client.config + }, original) + await entered.promise + original.epoch = epoch + original.receiptPolicy.receiptLimit = 1 + original.operation = { kind: 'floor', floor: snapshotJournalRevision('999999') } + release.resolve() + expect(await task.result).toMatchObject({ kind: 'floor', value: { floor: '0' } }) + await task.closed + } finally { + release.resolve() + receiptPolicy.receiptLimit = 128 + await f.close() + } +}) +test('unsupported configuration refuses before creating a native owner', async () => { + const task = maintainSnapshotJournal(async () => undefined, input()) + await expect(task.result).rejects.toThrow('requires file-backed') + await expect(task.closed).rejects.toThrow('requires file-backed') +}) +test.each(['incomplete', 'stale epoch', 'wrong ceiling', 'changed source DDL'] as const)( + 'refuses %s without advancing retention', + async state => { + const f = await fixture(state !== 'incomplete') + try { + if (state === 'stale epoch') f.request.epoch = epoch + if (state === 'wrong ceiling') f.request.ceiling = snapshotJournalRevision('999999') + if (state === 'changed source DDL') + await f.k.schema.alterTable('tx_labels', table => table.integer('unowned_column')) + await expect(f.storage.maintainSnapshotJournal(f.request)).rejects.toThrow() + await expect(f.storage.awaitSnapshotJournalMaintenanceCleanup()).resolves.toBeUndefined() + expect((await f.k('snapshot_journal_retention').first()).floor).toBe('0') + } finally { + await f.close() + } + } +) +test('disabled generation commits no floor or metadata deletion', async () => { + const f = await fixture() + try { + await f.k('snapshot_journal_clock').update({ enabled: 0, reason: 'capacity-exhausted' }) + expect(await f.storage.maintainSnapshotJournal(f.request)).toEqual({ kind: 'floor', value: undefined }) + expect((await f.k('snapshot_journal_retention').first()).floor).toBe('0') + } finally { + await f.close() + } +}) +test('cancellation retains source admission through rollback and physical cleanup', async () => { + const f = await fixture(), + entered = gate(), + release = gate(), + abort = new AbortController() + const advance = Receipts.advanceSnapshotJournalFloor + jest.spyOn(Receipts, 'advanceSnapshotJournalFloor').mockImplementation(async (...args) => { + const value = await advance(...args) + entered.resolve() + await release.promise + return value + }) + try { + const opening = f.storage.maintainSnapshotJournal(f.request, { signal: abort.signal }), + rejection = expect(opening).rejects.toThrow('cancelled') + await entered.promise + abort.abort() + await rejection + await expect(f.storage.maintainSnapshotJournal(f.request)).rejects.toThrow('already') + await expect(f.storage.openSnapshotJournalSource(identity, { ceiling, receiptPolicy })).rejects.toThrow('already') + let drained = false + const cleanup = f.storage.awaitSnapshotJournalMaintenanceCleanup().then(() => { + drained = true + }) + await new Promise(resolve => setImmediate(resolve)) + expect(drained).toBe(false) + release.resolve() + await cleanup + expect((await f.k('snapshot_journal_retention').first()).floor).toBe('0') + } finally { + release.resolve() + await f.close() + } +}) +test('physical cleanup failure remains observable and permanently fences admission', async () => { + const f = await fixture(), + close = Backend.closeSnapshotJournalCapturePool, + failure = new Error('synthetic pool closure failure') + jest.spyOn(Backend, 'closeSnapshotJournalCapturePool').mockImplementation(async (...args) => { + await close(...args) + throw failure + }) + try { + await expect(f.storage.maintainSnapshotJournal(f.request)).rejects.toBeInstanceOf( + Connections.SnapshotJournalConnectionCleanupError + ) + await expect(f.storage.awaitSnapshotJournalMaintenanceCleanup()).rejects.toBeInstanceOf( + Connections.SnapshotJournalConnectionCleanupError + ) + await expect(f.storage.maintainSnapshotJournal(f.request)).rejects.toThrow('destruction') + await expect(f.storage.destroy()).rejects.toBeInstanceOf(Connections.SnapshotJournalConnectionCleanupError) + } finally { + jest.restoreAllMocks() + await f.storage.destroy().catch(() => undefined) + await rm((f.k.client.config.connection as Knex.Sqlite3ConnectionConfig).filename.replace(/\/wallet\.sqlite$/, ''), { + recursive: true, + force: true + }) + } +}) + +function mysqlOwner() { + let completed = false + const execution = Promise.resolve() + const trx = { + executionPromise: execution, + isCompleted: jest.fn(() => completed), + commit: jest.fn(async () => { + completed = true + }), + rollback: jest.fn(async () => { + completed = true + }) + } + const writer = { transaction: jest.fn(async () => trx), destroy: jest.fn(async () => undefined) }, + reader = { destroy: jest.fn(async () => undefined) } + const factory = jest.requireActual<{ knex: typeof knex }>('knex') + jest + .spyOn(factory, 'knex') + .mockReturnValueOnce(writer as unknown as Knex) + .mockReturnValueOnce(reader as unknown as Knex) + jest.spyOn(Backend, 'prepareSnapshotJournalCaptureBackend').mockResolvedValue({ kind: 'mysql' }) + jest.spyOn(Backend, 'bindSnapshotJournalCaptureBackend').mockResolvedValue('a'.repeat(64)) + jest + .spyOn(Connections, 'withSnapshotJournalConnections') + .mockImplementation(async (_writer, _reader, active, run) => { + active() + return await run({}, {}) + }) + jest.spyOn(Fence, 'lockSnapshotJournalMaintenanceOwner').mockResolvedValue() + jest.spyOn(Mysql, 'readSnapshotJournalMysqlGeneration').mockResolvedValue({ + epoch, + source: 'a'.repeat(64), + plan: 'b'.repeat(64), + ceiling, + complete: true, + enabled: true, + nextObject: 59 + }) + jest.spyOn(Receipts, 'advanceSnapshotJournalFloor').mockResolvedValue({ + floor: snapshotJournalRevision('0'), + highWater: snapshotJournalRevision('1'), + examined: 0, + liveReceipts: 0 + }) + const config: Knex.Config = { + client: 'mysql2', + connection: { database: 'synthetic' }, + pool: { min: 8, max: 9 }, + acquireConnectionTimeout: 12000 + } + return { trx, writer, reader, config, factory } +} +test('MySQL setup preserves connection descriptors and bounds owned pool capacity and acquisition', async () => { + const m = mysqlOwner() + Object.defineProperty(m.config.connection, 'password', { enumerable: false, get: () => 'synthetic-secret' }) + const task = maintainSnapshotJournal(async () => m.config, input()) + expect(await task.result).toMatchObject({ kind: 'floor', value: { highWater: '1' } }) + await task.closed + for (const [config] of (m.factory.knex as unknown as jest.Mock).mock.calls) { + expect(config.pool).toMatchObject({ min: 0, max: 1 }) + expect(config.acquireConnectionTimeout).toBe(5000) + expect(Object.getOwnPropertyDescriptor(config.connection, 'password')).toEqual( + Object.getOwnPropertyDescriptor(m.config.connection, 'password') + ) + } + expect(m.config.pool).toMatchObject({ min: 8, max: 9 }) + expect(m.trx.commit).toHaveBeenCalledTimes(1) + expect(m.trx.rollback).not.toHaveBeenCalled() + expect(m.writer.destroy).toHaveBeenCalledTimes(1) + expect(m.reader.destroy).toHaveBeenCalledTimes(1) +}) +test.each(['backend', 'begin', 'generation', 'operation', 'commit', 'execution'] as const)( + 'retains %s failure and drains owned pools', + async phase => { + const m = mysqlOwner(), + failure = new Error('source ' + phase) + if (phase === 'backend') jest.spyOn(Backend, 'bindSnapshotJournalCaptureBackend').mockRejectedValue(failure) + else if (phase === 'begin') m.writer.transaction.mockRejectedValue(failure) + else if (phase === 'generation') jest.spyOn(Mysql, 'readSnapshotJournalMysqlGeneration').mockRejectedValue(failure) + else if (phase === 'operation') jest.spyOn(Receipts, 'advanceSnapshotJournalFloor').mockRejectedValue(failure) + else if (phase === 'commit') m.trx.commit.mockRejectedValue(failure) + else { + m.trx.executionPromise = Promise.reject(failure) + void m.trx.executionPromise.catch(() => undefined) + } + const task = maintainSnapshotJournal(async () => m.config, input()) + await expect(task.result).rejects.toBe(failure) + await expect(task.closed).rejects.toBe(failure) + expect(m.writer.destroy).toHaveBeenCalledTimes(1) + expect(m.reader.destroy).toHaveBeenCalledTimes(1) + } +) +test('rollback failure retains the operation and cleanup causes', async () => { + const m = mysqlOwner(), + source = new Error('source failure'), + cleanup = new Error('rollback failure') + jest.spyOn(Receipts, 'advanceSnapshotJournalFloor').mockRejectedValue(source) + m.trx.rollback.mockRejectedValue(cleanup) + const task = maintainSnapshotJournal(async () => m.config, input()) + const error = await task.result.then( + () => { + throw new Error('Expected cleanup failure') + }, + value => value as Connections.SnapshotJournalConnectionCleanupError + ) + expect(error).toBeInstanceOf(Connections.SnapshotJournalConnectionCleanupError) + expect((error.cause as AggregateError).errors).toEqual([source, cleanup]) + await expect(task.closed).rejects.toBe(error) +}) +test('pool destruction failure retains an undefined source rejection and its native cause', async () => { + const m = mysqlOwner(), + cleanup = new Error('pool destroy failure') + jest.spyOn(Receipts, 'advanceSnapshotJournalFloor').mockRejectedValue(undefined) + m.writer.destroy.mockRejectedValue(cleanup) + const task = maintainSnapshotJournal(async () => m.config, input()) + const error = await task.result.then( + () => { + throw new Error('Expected cleanup failure') + }, + value => value as Connections.SnapshotJournalConnectionCleanupError + ) + expect(error).toBeInstanceOf(Connections.SnapshotJournalConnectionCleanupError) + expect((error.cause as AggregateError).errors).toEqual([undefined, cleanup]) + await expect(task.closed).rejects.toBe(error) +}) +test('missing operation result fails closed after native providers drain', async () => { + const m = mysqlOwner() + jest.spyOn(Connections, 'withSnapshotJournalConnections').mockResolvedValue(undefined) + const task = maintainSnapshotJournal(async () => m.config, input()) + await expect(task.result).rejects.toThrow('generation') + await expect(task.closed).rejects.toThrow('generation') + expect(m.writer.destroy).toHaveBeenCalledTimes(1) +}) +test('cleanup-only pool failure cannot publish an otherwise committed result', async () => { + const m = mysqlOwner(), + cleanup = new Error('native pool closure failed') + m.reader.destroy.mockRejectedValue(cleanup) + const task = maintainSnapshotJournal(async () => m.config, input()) + const error = await task.result.then( + () => { + throw new Error('Expected cleanup failure') + }, + value => value as Connections.SnapshotJournalConnectionCleanupError + ) + expect((error.cause as AggregateError).errors).toEqual([cleanup]) + await expect(task.closed).rejects.toBe(error) + expect(m.trx.commit).toHaveBeenCalledTimes(1) +}) +test('a driver that still reports an active transaction must drain it before returning', async () => { + const m = mysqlOwner(), + cleanup = new Error('active transaction cleanup failed') + m.trx.isCompleted.mockReturnValue(false) + m.trx.rollback.mockRejectedValue(cleanup) + const task = maintainSnapshotJournal(async () => m.config, input()) + const error = await task.result.then( + () => { + throw new Error('Expected cleanup failure') + }, + value => value as Connections.SnapshotJournalConnectionCleanupError + ) + expect((error.cause as AggregateError).errors).toEqual([cleanup]) + await expect(task.closed).rejects.toBe(error) + expect(m.trx.commit).toHaveBeenCalledTimes(1) + expect(m.trx.rollback).toHaveBeenCalledTimes(1) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.ts new file mode 100644 index 000000000..e7150957d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.ts @@ -0,0 +1,195 @@ +import { knex as createKnex, type Knex } from 'knex' +import { WERR_INVALID_OPERATION, WERR_NOT_IMPLEMENTED } from '../../../sdk/WERR_errors' +import { + advanceSnapshotJournalFloor, + snapshotJournalReceiptPolicy, + type SnapshotJournalReceiptPolicy +} from './SnapshotJournalReceipt' +import { + collectSnapshotJournalTombstones, + snapshotJournalCollectionRequest, + type SnapshotJournalCollectionRequest +} from './SnapshotJournalCollection' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' +import { readSnapshotJournalSqliteGeneration } from './SnapshotJournalSqliteGeneration' +import { readSnapshotJournalMysqlGeneration } from './SnapshotJournalMysqlGeneration' +import { withSnapshotJournalConnections, SnapshotJournalConnectionCleanupError } from './SnapshotJournalConnections' +import { + prepareSnapshotJournalCaptureBackend, + bindSnapshotJournalCaptureBackend, + closeSnapshotJournalCapturePool +} from './SnapshotJournalCaptureBackend' +import { lockSnapshotJournalMaintenanceOwner } from './SnapshotJournalMaintenanceFence' +import { + runSnapshotJournalMaintenanceTask, + type SnapshotJournalMaintenanceOptions, + type SnapshotJournalMaintenanceTask +} from './SnapshotJournalMaintenanceTask' + +type Operation = + { kind: 'floor'; floor: SnapshotJournalRevision } | { kind: 'collect'; page: SnapshotJournalCollectionRequest } +export interface SnapshotJournalMaintenanceRequest { + epoch: string + ceiling: SnapshotJournalRevision + receiptPolicy: SnapshotJournalReceiptPolicy + operation: Operation +} +export type SnapshotJournalMaintenanceResult = + | { + kind: 'floor' + value: Awaited> + } + | { + kind: 'collect' + value: Awaited> + } + +function invalid(): never { + throw new WERR_INVALID_OPERATION('Snapshot journal maintenance generation is unavailable or changed') +} +function request(input: SnapshotJournalMaintenanceRequest): SnapshotJournalMaintenanceRequest { + const epoch = input.epoch, + ceiling = snapshotJournalRevision(input.ceiling), + receiptPolicy = snapshotJournalReceiptPolicy(input.receiptPolicy) + if ( + typeof epoch !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(epoch) || + ceiling === '0' + ) + return invalid() + const operation = input.operation + if (operation.kind === 'floor') + return { + epoch, + ceiling, + receiptPolicy, + operation: { + kind: 'floor', + floor: snapshotJournalRevision(operation.floor) + } + } + if (operation.kind !== 'collect') return invalid() + const page = snapshotJournalCollectionRequest(operation.page) + if (page.epoch !== epoch) return invalid() + return { + epoch, + ceiling, + receiptPolicy, + operation: { kind: 'collect', page } + } +} +function ownedConfig(config: Knex.Config): Knex.Config { + const connection = config.connection as object + return { + ...config, + connection: Object.create(Object.getPrototypeOf(connection), Object.getOwnPropertyDescriptors(connection)), + pool: { ...config.pool, min: 0, max: 1 }, + acquireConnectionTimeout: Math.min(config.acquireConnectionTimeout ?? 5000, 5000) + } +} +async function operation(k: Knex, input: SnapshotJournalMaintenanceRequest): Promise { + if (input.operation.kind === 'floor') + return { + kind: 'floor', + value: await advanceSnapshotJournalFloor(k, input.operation.floor) + } + return { + kind: 'collect', + value: await collectSnapshotJournalTombstones(k, input.operation.page) + } +} + +/** One global, bounded internal maintenance operation on a complete owned generation. + * No source rows or physical files are removed. Cancellation rejects the result + * promptly; closed owns all native cleanup and determines provider admission. + * Raw operator DDL is outside the configured Knex migration-owner contract. + * This neither registers a journal nor advertises an incremental capability. + */ +export function maintainSnapshotJournal( + resolveConfig: () => Promise, + input: SnapshotJournalMaintenanceRequest, + options: SnapshotJournalMaintenanceOptions = {} +): SnapshotJournalMaintenanceTask { + const detached = request(input) + return runSnapshotJournalMaintenanceTask(async assertActive => { + let writer: Knex | undefined, verifier: Knex | undefined, result: SnapshotJournalMaintenanceResult | undefined + let failure: { error: unknown } | undefined + try { + const config = await resolveConfig() + assertActive() + if (config === undefined) + throw new WERR_NOT_IMPLEMENTED('Snapshot journal maintenance requires file-backed SQLite WAL or static MySQL') + const expected = await prepareSnapshotJournalCaptureBackend(config) + assertActive() + writer = createKnex(ownedConfig(config)) + verifier = createKnex(ownedConfig(config)) + result = await withSnapshotJournalConnections( + writer, + verifier, + assertActive, + async (write, read) => { + await bindSnapshotJournalCaptureBackend(writer!, write, verifier!, read, expected) + assertActive() + if (expected.kind === 'sqlite') await writer!.raw('PRAGMA busy_timeout = 0').connection(write) + const trx = await writer!.transaction({ connection: write }) + let operationFailure: { error: unknown } | undefined + let value: SnapshotJournalMaintenanceResult | undefined + void trx.executionPromise.catch(() => undefined) + try { + assertActive() + await lockSnapshotJournalMaintenanceOwner(trx, config.migrations) + assertActive() + const generation = + expected.kind === 'sqlite' + ? await readSnapshotJournalSqliteGeneration(trx, detached.receiptPolicy, config.migrations) + : await readSnapshotJournalMysqlGeneration( + trx, + detached.ceiling, + detached.receiptPolicy, + config.migrations + ) + if (!generation.complete || generation.epoch !== detached.epoch || generation.ceiling !== detached.ceiling) + return invalid() + assertActive() + value = await operation(trx, detached) + assertActive() + await trx.commit() + await trx.executionPromise + } catch (error) { + operationFailure = { error } + } + if (!trx.isCompleted()) { + try { + await trx.rollback() + await trx.executionPromise + } catch (error) { + throw new SnapshotJournalConnectionCleanupError( + new AggregateError( + [...(operationFailure === undefined ? [] : [operationFailure.error]), error], + 'Snapshot journal maintenance transaction did not drain' + ) + ) + } + } + if (operationFailure !== undefined) throw operationFailure.error + return value + }, + closeSnapshotJournalCapturePool + ) + } catch (error) { + failure = { error } + } + const settled = await Promise.allSettled([writer?.destroy(), verifier?.destroy()]) + const failed = settled.filter(value => value.status === 'rejected') + if (failed.length) + throw new SnapshotJournalConnectionCleanupError( + new AggregateError( + [...(failure === undefined ? [] : [failure.error]), ...failed.map(value => value.reason)], + 'Snapshot journal maintenance owned pools did not close' + ) + ) + if (failure !== undefined) throw failure.error + if (result === undefined) return invalid() + return result + }, options) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.test.ts new file mode 100644 index 000000000..0686a9cca --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.test.ts @@ -0,0 +1,245 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { lockSnapshotJournalMaintenanceOwner as lock } from './SnapshotJournalMaintenanceFence' +import { SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL } from './SnapshotJournalSqliteClock' + +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'ts569-maintenance-owner-')) + const config: Knex.Config = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + } + const k = knex(config), + peer = knex(config) + await k.raw('PRAGMA journal_mode=WAL') + await k.raw('PRAGMA busy_timeout=0') + await peer.raw('PRAGMA busy_timeout=0') + await k.raw(SNAPSHOT_JOURNAL_SQLITE_CLOCK_DDL) + await k('snapshot_journal_clock').insert({ id: 1, revision: 0, ceiling: 10000, enabled: 1, reason: null }) + await k.schema.createTable('knex_migrations_lock', table => { + table.increments('index') + table.integer('is_locked').notNullable() + }) + await k('knex_migrations_lock').insert({ is_locked: 0 }) + return { + k, + peer, + async close() { + await Promise.all([k.destroy(), peer.destroy()]) + await rm(directory, { recursive: true, force: true }) + } + } +} +afterEach(() => jest.restoreAllMocks()) +test('requires a caller transaction and an approved backend before any mutation', async () => { + const f = await fixture() + try { + await expect(lock(f.k)).rejects.toThrow('migration owner') + await expect( + lock({ isTransaction: true, client: { config: { client: 'pg' } } } as unknown as Knex) + ).rejects.toThrow('migration owner') + expect((await f.k('snapshot_journal_clock').first()).revision).toBe(0) + } finally { + await f.close() + } +}) +test('reserves WAL writing without consuming an event or changing the Knex owner', async () => { + const f = await fixture() + try { + const before = await f.k('knex_migrations_lock') + await f.k.transaction(trx => lock(trx)) + expect(await f.k('knex_migrations_lock')).toEqual(before) + expect((await f.k('snapshot_journal_clock').first()).revision).toBe(0) + } finally { + await f.close() + } +}) +test.each(['claimed', 'missing', 'duplicate', 'unsafe index'] as const)( + 'refuses %s migration ownership atomically', + async state => { + const f = await fixture() + try { + if (state === 'claimed') await f.k('knex_migrations_lock').update({ is_locked: 1 }) + else if (state === 'missing') await f.k('knex_migrations_lock').delete() + else if (state === 'duplicate') await f.k('knex_migrations_lock').insert({ is_locked: 0 }) + else await f.k('knex_migrations_lock').update({ index: 0 }) + await expect(f.k.transaction(trx => lock(trx))).rejects.toThrow('migration owner') + expect((await f.k('snapshot_journal_clock').first()).revision).toBe(0) + } finally { + await f.close() + } + } +) +test('configured migration table and schema use their actual owner', async () => { + const f = await fixture() + try { + await f.k.schema.renameTable('knex_migrations_lock', 'custom_migrations_lock') + await f.k.transaction(trx => lock(trx, { tableName: 'custom_migrations', schemaName: 'main' })) + await expect(f.k.transaction(trx => lock(trx))).rejects.toThrow() + for (const config of [ + { tableName: '' }, + { tableName: 'x'.repeat(513) }, + { schemaName: '' }, + { schemaName: 'x'.repeat(65) } + ]) { + await expect(f.k.transaction(trx => lock(trx, config))).rejects.toThrow('migration owner') + } + expect((await f.k('snapshot_journal_clock').first()).revision).toBe(0) + } finally { + await f.close() + } +}) +test('held WAL writer refuses immediately instead of queuing maintenance', async () => { + const f = await fixture(), + held = await f.peer.transaction() + try { + await lock(held) + await expect(f.k.transaction(trx => lock(trx))).rejects.toMatchObject({ code: 'SQLITE_BUSY' }) + } finally { + await held.rollback() + await f.close() + } +}) +test.each(['busy timeout', 'journal mode', 'clock missing'] as const)('refuses unsafe %s', async state => { + const f = await fixture() + try { + if (state === 'busy timeout') await f.k.raw('PRAGMA busy_timeout=1') + else if (state === 'journal mode') { + await f.peer.destroy() + await f.k.raw('PRAGMA journal_mode=DELETE') + } else await f.k('snapshot_journal_clock').delete() + await expect(f.k.transaction(trx => lock(trx))).rejects.toThrow('migration owner') + } finally { + await f.close() + } +}) +test('MySQL current owner lock is NOWAIT and reserves no global clock during metadata checks', async () => { + const calls: string[] = [] + let rows: Array<{ index: unknown; is_locked: unknown }> = [{ index: 1, is_locked: 0 }] + const query = { + select(...columns: string[]) { + calls.push('select:' + columns.join(',')) + return this + }, + limit(value: number) { + calls.push('limit:' + value) + return this + }, + withSchema(value: string) { + calls.push('schema:' + value) + return this + }, + forUpdate() { + calls.push('current') + return this + }, + noWait() { + calls.push('nowait') + return this + } + } + const k = Object.assign( + (name: string) => { + calls.push(name) + return Object.assign(Promise.resolve(rows), query) + }, + { isTransaction: true, client: { config: { client: 'mysql2' } } } + ) as unknown as Knex + await lock(k, { tableName: 'x'.repeat(59), schemaName: 's'.repeat(64) }) + expect(calls).toEqual([ + 'x'.repeat(59) + '_lock', + 'select:index,is_locked', + 'limit:2', + 'schema:' + 's'.repeat(64), + 'current', + 'nowait' + ]) + calls.length = 0 + k.client.config.client = 'mysql' + await lock(k) + expect(calls).toEqual(['knex_migrations_lock', 'select:index,is_locked', 'limit:2', 'current', 'nowait']) + for (const config of [ + { tableName: { length: 1, toString: () => 'knex_migrations' } }, + { schemaName: ['synthetic'] } + ]) { + calls.length = 0 + await expect(lock(k, config as Knex.MigratorConfig)).rejects.toThrow('migration owner') + expect(calls).toHaveLength(0) + } + await expect(lock(k, { tableName: 'x'.repeat(60) })).rejects.toThrow('migration owner') + for (const value of ['1', 0, -1, Number.MAX_SAFE_INTEGER + 1]) { + rows = [{ index: value, is_locked: 0 }] + await expect(lock(k)).rejects.toThrow('migration owner') + } + rows = [{ index: 1, is_locked: '0' }] + await expect(lock(k)).rejects.toThrow('migration owner') +}) + +test.each(['timeout missing', 'timeout duplicate', 'mode missing', 'mode duplicate'] as const)( + 'refuses malformed SQLite %s responses before reserving its writer', + async state => { + const query = jest.fn() + const k = Object.assign(query, { + isTransaction: true, + client: { config: { client: 'better-sqlite3' } }, + raw: jest.fn(async (sql: string) => { + if (sql === 'PRAGMA busy_timeout') + return state === 'timeout missing' + ? [] + : Array.from({ length: state === 'timeout duplicate' ? 2 : 1 }, () => ({ timeout: 0 })) + return state === 'mode missing' + ? [] + : Array.from({ length: state === 'mode duplicate' ? 2 : 1 }, () => ({ journal_mode: 'wal' })) + }) + }) as unknown as Knex + await expect(lock(k)).rejects.toThrow('migration owner') + expect(query).not.toHaveBeenCalled() + } +) + +test('the sqlite3 prerequisite selects the exact owner fields after writer reservation', async () => { + const calls: string[] = [] + const query = { + where(id: string, value: number) { + calls.push('where:' + id + ':' + value) + return this + }, + async update() { + calls.push('reserve') + return 1 + }, + select(...columns: string[]) { + calls.push('select:' + columns.join(',')) + return this + }, + limit(value: number) { + calls.push('limit:' + value) + return this + } + } + const k = Object.assign( + (table: string) => { + calls.push(table) + return Object.assign(Promise.resolve([{ index: 1, is_locked: 0 }]), query) + }, + { + isTransaction: true, + client: { config: { client: 'sqlite3' } }, + ref: (name: string) => name, + raw: async (sql: string) => (sql === 'PRAGMA busy_timeout' ? [{ timeout: 0 }] : [{ journal_mode: 'wal' }]) + } + ) as unknown as Knex + await lock(k, { tableName: 'x'.repeat(512) }) + expect(calls).toEqual([ + 'snapshot_journal_clock', + 'where:id:1', + 'reserve', + 'x'.repeat(512) + '_lock', + 'select:index,is_locked', + 'limit:2' + ]) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.ts new file mode 100644 index 000000000..970cd6608 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.ts @@ -0,0 +1,55 @@ +import type { Knex } from 'knex' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' + +function invalid(): never { + throw new WERR_INVALID_OPERATION('Snapshot journal maintenance cannot exclude the configured migration owner') +} + +/** Internal prerequisite for complete owned-generation validation and one bounded + * maintenance operation. Use a fresh caller-owned transaction. MySQL locks only + * the migration owner during metadata validation; foreground clock writers can + * proceed until the bounded floor/page operation reserves its current clock. + * SQLite reserves the writer before any read can pin an old snapshot. Neither + * reservation allocates an event position, and the owner row remains unchanged. + * Every approved schema operation must use that migration owner. This does not + * exclude raw operator DDL, publish a migration, or authorize source-row removal. + */ +export async function lockSnapshotJournalMaintenanceOwner(k: Knex, config?: Knex.MigratorConfig): Promise { + const driver = k.client.config.client + const sqlite = driver === 'better-sqlite3' || driver === 'sqlite3' + if (!k.isTransaction || (!sqlite && driver !== 'mysql' && driver !== 'mysql2')) return invalid() + const table = config?.tableName ?? 'knex_migrations', + schema = config?.schemaName + if ( + typeof table !== 'string' || + table.length === 0 || + table.length > (sqlite ? 512 : 59) || + (schema !== undefined && (typeof schema !== 'string' || schema.length === 0 || schema.length > 64)) + ) + return invalid() + if (sqlite) { + const timeouts: Array<{ timeout: number }> = await k.raw('PRAGMA busy_timeout') + const modes: Array<{ journal_mode: string }> = await k.raw('PRAGMA journal_mode') + if (timeouts.length !== 1 || timeouts[0].timeout !== 0 || modes.length !== 1 || modes[0].journal_mode !== 'wal') + return invalid() + if ( + (await k('snapshot_journal_clock') + .where('id', 1) + .update({ revision: k.ref('revision') })) !== 1 + ) + return invalid() + } + const query = k(table + '_lock') + .select('index', 'is_locked') + .limit(2) + if (schema !== undefined) query.withSchema(schema) + if (!sqlite) query.forUpdate().noWait() + const rows: Array<{ index: unknown; is_locked: unknown }> = await query + if ( + rows.length !== 1 || + !Number.isSafeInteger(rows[0].index) || + (rows[0].index as number) < 1 || + rows[0].is_locked !== 0 + ) + return invalid() +} diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.test.ts new file mode 100644 index 000000000..4e5e3991b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.test.ts @@ -0,0 +1,334 @@ +import { getEventListeners } from 'node:events' +import { runSnapshotJournalMaintenanceTask as run } from './SnapshotJournalMaintenanceTask' +import { WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { SnapshotCancelledError } from '../SnapshotCancelledError' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' + +function gate() { + let resolve!: () => void + let reject!: (error: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +async function pending(promise: Promise) { + let settled = false + void promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) +} +afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() +}) + +test('invalid lifetimes refuse before allocating native work', () => { + for (const lifetimeMs of [0, -1, 1.5, NaN, Infinity, 30001, '100']) { + let allocated = false + expect(() => + run( + async () => { + allocated = true + }, + { lifetimeMs: lifetimeMs as number } + ) + ).toThrow(WERR_INVALID_PARAMETER) + expect(() => run(async () => undefined, { lifetimeMs: lifetimeMs as number })).toThrow( + 'The lifetimeMs parameter must be an integer from 1 to 30000' + ) + expect(allocated).toBe(false) + } +}) +test('already aborted work never enters the native callback', async () => { + jest.useFakeTimers() + let allocated = false + const task = run( + async () => { + allocated = true + }, + { signal: AbortSignal.abort() } + ) + await expect(task.result).rejects.toBeInstanceOf(SnapshotCancelledError) + await task.closed + await task.close() + expect(allocated).toBe(false) + expect(jest.getTimerCount()).toBe(0) +}) +test.each([1, 30000])('admits exact lifetime boundary %i and releases its timer and listener', async lifetimeMs => { + jest.useFakeTimers() + const controller = new AbortController() + const task = run(async () => 23, { signal: controller.signal, lifetimeMs }) + expect(jest.getTimerCount()).toBe(1) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(1) + expect(await task.result).toBe(23) + await task.closed + expect(jest.getTimerCount()).toBe(0) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + await Promise.all([task.close(), task.close()]) + controller.abort() + expect(await task.result).toBe(23) + expect(jest.getTimerCount()).toBe(0) +}) +test('cancellation detaches result-lifetime resources while native cleanup remains owned', async () => { + jest.useFakeTimers() + const controller = new AbortController(), + entered = gate(), + drain = gate() + let physicallyClosed = false + const task = run( + async active => { + entered.resolve() + await drain.promise + physicallyClosed = true + active() + }, + { signal: controller.signal } + ) + await entered.promise + expect(jest.getTimerCount()).toBe(1) + controller.abort() + await expect(task.result).rejects.toBeInstanceOf(SnapshotCancelledError) + expect(jest.getTimerCount()).toBe(0) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(physicallyClosed).toBe(false) + drain.resolve() + await task.closed + expect(physicallyClosed).toBe(true) +}) +test('the first cancellation reason remains exact through repeated close and native drain', async () => { + const controller = new AbortController(), + entered = gate(), + drain = gate() + let nativeStop: unknown + const task = run( + async active => { + entered.resolve() + await drain.promise + try { + active() + } catch (error) { + nativeStop = error + throw error + } + }, + { signal: controller.signal } + ) + await entered.promise + controller.abort() + const reason = await task.result.catch(error => error as unknown) + expect(reason).toBeInstanceOf(SnapshotCancelledError) + const closing = Promise.all([task.close(), task.close()]) + drain.resolve() + await closing + expect(nativeStop).toBe(reason) + expect(await task.result.catch(error => error as unknown)).toBe(reason) +}) +test('normal results and closure wait for physical cleanup and close remains idempotent', async () => { + const cleanup = gate() + const task = run(async () => { + await cleanup.promise + return 23 + }) + await pending(task.result) + await pending(task.closed) + cleanup.resolve() + expect(await task.result).toBe(23) + await task.closed + await Promise.all([task.close(), task.close()]) +}) +test('cancellation rejects promptly but native drain owns closure', async () => { + const cleanup = gate(), + entered = gate(), + controller = new AbortController() + const task = run( + async active => { + entered.resolve() + await cleanup.promise + active() + return 'late' + }, + { signal: controller.signal } + ) + await entered.promise + controller.abort() + await expect(task.result).rejects.toBeInstanceOf(SnapshotCancelledError) + await pending(task.closed) + const closing = task.close() + await pending(closing) + cleanup.resolve() + await closing +}) +test('expiry discards late results without releasing before native drain', async () => { + const cleanup = gate(), + entered = gate() + const task = run( + async () => { + entered.resolve() + await cleanup.promise + return 'late' + }, + { lifetimeMs: 20 } + ) + await entered.promise + const error = await task.result.catch(error => error as unknown) + expect(error).toBeInstanceOf(SnapshotResourceLimitError) + expect(error).toMatchObject({ message: 'Snapshot journal maintenance expired' }) + await pending(task.closed) + cleanup.resolve() + await task.closed +}) +test('wall and monotonic deadlines independently refuse late results', async () => { + for (const clock of ['wall', 'monotonic']) { + const wall = Date.now() + let now = wall, + monotonic = 100 + jest.spyOn(Date, 'now').mockImplementation(() => now) + jest.spyOn(performance, 'now').mockImplementation(() => monotonic) + const cleanup = gate(), + entered = gate() + const task = run( + async () => { + entered.resolve() + await cleanup.promise + return 'late' + }, + { lifetimeMs: 1000 } + ) + await entered.promise + if (clock === 'wall') now += 1000 + else { + now -= 100000 + monotonic += 1000 + } + cleanup.resolve() + const error = await task.result.catch(error => error as unknown) + expect(error).toBeInstanceOf(SnapshotResourceLimitError) + expect(error).toMatchObject({ message: 'Snapshot journal maintenance expired' }) + await task.closed + jest.restoreAllMocks() + } +}) +test('post-commit cancellation does not claim rollback of durable state', async () => { + const cleanup = gate(), + committed = gate(), + controller = new AbortController() + let floor = 0 + const task = run( + async () => { + floor = 8 + committed.resolve() + await cleanup.promise + return floor + }, + { signal: controller.signal } + ) + await committed.promise + controller.abort() + await expect(task.result).rejects.toBeInstanceOf(SnapshotCancelledError) + await pending(task.closed) + expect(floor).toBe(8) + cleanup.resolve() + await task.closed + expect(floor).toBe(8) +}) +test('unrelated native cleanup errors remain observable after cancellation', async () => { + const cleanup = gate(), + entered = gate(), + controller = new AbortController(), + error = new Error('native close failure') + const task = run( + async () => { + entered.resolve() + await cleanup.promise + }, + { signal: controller.signal } + ) + await entered.promise + controller.abort() + await expect(task.result).rejects.toBeInstanceOf(SnapshotCancelledError) + cleanup.reject(error) + await expect(task.closed).rejects.toBe(error) + await expect(task.close()).rejects.toBe(error) +}) +test('ordinary source failure including undefined cannot match an unset cancellation', async () => { + for (const error of [new Error('source failure'), undefined]) { + const task = run(async () => { + throw error + }) + const outcomes = await Promise.allSettled([task.result, task.closed]) + expect(outcomes).toEqual([ + { status: 'rejected', reason: error }, + { status: 'rejected', reason: error } + ]) + } +}) +test('early manual close refuses allocation and repeated close drains the same owner', async () => { + let allocated = false + const task = run(async () => { + allocated = true + }) + await Promise.all([task.close(), task.close()]) + await expect(task.result).rejects.toThrow('closed') + expect(allocated).toBe(false) +}) +async function executeSchedule(stage: number, index: number): Promise { + const controller = new AbortController(), + entered = gate(), + commit = gate(), + committed = gate(), + drain = gate() + let durable = false, + allocated = false + const task = run( + async active => { + allocated = true + entered.resolve() + await commit.promise + active() + durable = true + committed.resolve() + await drain.promise + return index + }, + { signal: controller.signal } + ) + if (stage === 0) controller.abort() + else { + await entered.promise + if (stage === 1) controller.abort() + commit.resolve() + if (stage >= 2) { + await committed.promise + if (stage === 2) controller.abort() + } + if (stage !== 1) await pending(task.closed) + } + drain.resolve() + const outcome = await task.result.then( + value => ({ value }), + error => ({ error: error as unknown }) + ) + await task.closed + if (stage === 3) expect(outcome).toEqual({ value: index }) + else expect(outcome).toEqual({ error: expect.any(SnapshotCancelledError) }) + expect(allocated).toBe(stage !== 0) + expect(durable).toBe(stage >= 2) +} + +test('300 seeded cancellation/commit/drain schedules retain ownership', async () => { + let seed = 3242026 + const random = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed + } + for (let index = 0; index < 300; index++) await executeSchedule(random() % 4, index) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.ts new file mode 100644 index 000000000..af360a267 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.ts @@ -0,0 +1,91 @@ +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { SnapshotCancelledError } from '../SnapshotCancelledError' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' + +export interface SnapshotJournalMaintenanceOptions { + /** Total admission/result lifetime; physical native cleanup must still drain. */ + lifetimeMs?: number + signal?: AbortSignal +} +export interface SnapshotJournalMaintenanceTask { + result: Promise + closed: Promise + close: () => Promise +} +function deferred() { + let resolve!: (value: T) => void, reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +/** Private owner lifecycle. The run callback must await every native operation + * and all physical cleanup. Rejecting result never releases the provider slot; + * only closed permits admission. A cancellation after commit discards the late + * result without claiming rollback; floor/page retry uses database state. + */ +export function runSnapshotJournalMaintenanceTask( + run: (assertActive: () => void) => Promise, + options: SnapshotJournalMaintenanceOptions = {} +): SnapshotJournalMaintenanceTask { + const lifetimeMs = options.lifetimeMs ?? 5000, + signal = options.signal + if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs < 1 || lifetimeMs > 30000) + throw new WERR_INVALID_PARAMETER('lifetimeMs', 'an integer from 1 to 30000') + const result = deferred(), + closed = deferred() + const expiresAt = Date.now() + lifetimeMs, + started = performance.now() + let stopReason: WERR_INVALID_OPERATION | undefined, + finished = false + let timer: ReturnType | undefined + void result.promise.catch(() => undefined) + void closed.promise.catch(() => undefined) + const detach = () => { + if (timer !== undefined) clearTimeout(timer) + signal?.removeEventListener('abort', abort) + } + const stop = (reason: WERR_INVALID_OPERATION) => { + if (finished || stopReason !== undefined) return + stopReason = reason + detach() + result.reject(reason) + } + const abort = () => stop(new SnapshotCancelledError('Snapshot journal maintenance was cancelled')) + const assertActive = () => { + if (Date.now() >= expiresAt || performance.now() - started >= lifetimeMs) + stop(new SnapshotResourceLimitError('Snapshot journal maintenance expired')) + if (stopReason !== undefined) throw stopReason + } + const finish = async () => { + try { + assertActive() + const value = await run(assertActive) + assertActive() + closed.resolve() + result.resolve(value) + } catch (error) { + if (stopReason !== undefined && error === stopReason) closed.resolve() + else closed.reject(error) + result.reject(error) + } finally { + finished = true + detach() + } + } + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) abort() + if (stopReason === undefined) + timer = setTimeout(() => stop(new SnapshotResourceLimitError('Snapshot journal maintenance expired')), lifetimeMs) + void Promise.resolve().then(finish) + return { + result: result.promise, + closed: closed.promise, + async close() { + stop(new WERR_INVALID_OPERATION('Snapshot journal maintenance is closed')) + await closed.promise + } + } +} diff --git a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs index 398ebe34a..70a1d2bad 100644 --- a/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/runSnapshotJournalMysql.cjs @@ -12,7 +12,9 @@ const journalFixtureGroups = Object.freeze([ 'receipts', 'capture', 'retention-rc', - 'retention-rr' + 'retention-rr', + 'maintenance-rc', + 'maintenance-rr' ]) const execute = (file, args, options) => new Promise((resolve, reject) => { @@ -28,6 +30,8 @@ const execute = (file, args, options) => }) function fixtureScript(group) { + if (group === 'maintenance-rc') return 'snapshotJournalMaintenanceMysqlRc.cjs' + if (group === 'maintenance-rr') return 'snapshotJournalMaintenanceMysqlRr.cjs' if (group === 'retention-rc') return 'snapshotJournalRetentionMysqlRc.cjs' if (group === 'retention-rr') return 'snapshotJournalRetentionMysqlRr.cjs' if (group === 'capture') return 'snapshotJournalCaptureMysql.cjs' @@ -36,7 +40,13 @@ function fixtureScript(group) { return 'snapshotJournalReceiptMysql.cjs' } function fixtureTimeout(group) { - if (group === 'generation' || group === 'capture' || group.startsWith('retention-')) return 180000 + if ( + group === 'generation' || + group === 'capture' || + group.startsWith('retention-') || + group.startsWith('maintenance-') + ) + return 180000 if (group === 'server-crash') return 240000 return 60000 } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceChild.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceChild.cjs new file mode 100644 index 000000000..0a3fc0c7b --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceChild.cjs @@ -0,0 +1,56 @@ +const assert = require('node:assert/strict') +const input = JSON.parse(process.argv[2]) +const { maintainSnapshotJournal } = require('../../out/src/storage/snapshot/journal/SnapshotJournalMaintenance.js') +const backend = require('../../out/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.js') +const { inject } = require('./snapshotJournalMaintenanceProcessLoss.cjs') +async function main() { + assert(['sqlite', 'mysql'].includes(input.backend)) + const k = + input.backend === 'mysql' + ? require('./snapshotJournalMysqlConnection.cjs').open(true) + : require('knex').knex({ + client: 'better-sqlite3', + connection: { filename: input.filename }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + process.once('disconnect', () => process.exit(1)) + const deadline = setTimeout(() => process.exit(2), 20000) + deadline.unref() + const bind = backend.bindSnapshotJournalCaptureBackend + backend.bindSnapshotJournalCaptureBackend = async (...args) => { + const value = await bind(...args) + inject(args[0], input.operation, input.phase) + return value + } + try { + if (input.backend === 'mysql') { + assert(['READ COMMITTED', 'REPEATABLE READ'].includes(input.isolation)) + k.client.config.pool = { + ...k.client.config.pool, + afterCreate(connection, done) { + connection.query('SET SESSION TRANSACTION ISOLATION LEVEL ' + input.isolation, error => + done(error, connection) + ) + } + } + } + const operation = + input.operation === 'floor' ? { kind: 'floor', floor: input.floor } : { kind: 'collect', page: input.request } + const task = maintainSnapshotJournal(async () => k.client.config, { + epoch: input.epoch, + ceiling: input.ceiling, + receiptPolicy: input.receiptPolicy, + operation + }) + await task.result + await task.closed + throw new Error('Did not reach owned maintenance process-loss boundary') + } finally { + await k.destroy() + } +} +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceCuts.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceCuts.cjs new file mode 100644 index 000000000..286704304 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceCuts.cjs @@ -0,0 +1,140 @@ +const { maintainSnapshotJournal } = require('../../out/src/storage/snapshot/journal/SnapshotJournalMaintenance.js') +async function maintain(k, input, operation) { + const task = maintainSnapshotJournal(async () => k.client.config, { + epoch: input.epoch, + ceiling: input.ceiling, + receiptPolicy: input.receiptPolicy, + operation + }) + const result = await task.result + await task.closed + return result.value +} +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { tables, exact } = require('./snapshotJournalNativeFixture.cjs') +const { killAt } = require('./snapshotJournalMaintenanceProcessLoss.cjs') +const { collectSnapshotJournalReceipts } = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js') + +const { snapshotArchiveDatabaseNow } = require('../../out/src/storage/snapshot/archive/SnapshotArchiveSql.js') +const { snapshotJournalRevisionText } = require('../../out/src/storage/snapshot/journal/SnapshotJournalRevisionSql.js') +async function source(k) { + const rows = {} + await runInSeries(tables, async table => { + rows[table] = await k(table) + }) + return rows +} +async function newest(k) { + let prefix = '0' + await runInSeries(['scope', 'physical'], async stream => { + const table = 'snapshot_journal_' + stream + const row = await k(table) + .select({ revisionText: snapshotJournalRevisionText(k, 'revision') }) + .orderBy(table + '.revision', 'desc') + .first() + if (row !== undefined && BigInt(row.revisionText) > BigInt(prefix)) prefix = row.revisionText + }) + assert(BigInt(prefix) > 0n) + return prefix +} +module.exports = async function cuts(k, input) { + await k('snapshot_journal_receipts').update({ expiresAt: (await snapshotArchiveDatabaseNow(k)) - 1 }) + await k.transaction(t => collectSnapshotJournalReceipts(t)) + assert.equal((await k('snapshot_journal_receipts')).length, 0) + const results = [] + const clock = async () => + ( + await k('snapshot_journal_clock') + .select({ revision: snapshotJournalRevisionText(k, 'revision') }) + .first() + ).revision + await runInSeries(['before-commit', 'after-commit'], async phase => { + await k('tx_labels') + .where('txLabelId', 1) + .update({ label: 'floor process loss ' + phase }) + const floor = await newest(k), + previous = String((await k('snapshot_journal_retention').first()).floor), + beforeClock = input.backend === 'sqlite' ? await clock() : undefined + const beforeSource = await source(k) + await killAt({ ...input, operation: 'floor', phase, floor, marker: join(input.directory, 'floor-' + phase) }) + assert.equal( + String((await k('snapshot_journal_retention').first()).floor), + phase === 'after-commit' ? floor : previous + ) + if (input.backend === 'sqlite') + assert.equal(await clock(), phase === 'after-commit' ? String(BigInt(beforeClock) + 1n) : beforeClock) + assert.deepEqual(await source(k), beforeSource) + const recovered = await maintain(k, input, { kind: 'floor', floor }) + assert.equal(recovered.floor, floor) + await exact(k) + results.push({ operation: 'floor', phase, atomic: true, lostAcknowledgementRecovered: phase === 'after-commit' }) + }) + let firstId = 1000 + await runInSeries(['scope', 'physical'], async stream => { + await runInSeries(['after-first-delete', 'before-commit', 'after-commit'], async phase => { + const ids = [firstId++, firstId++, firstId++], + date = input.backend === 'mysql' ? new Date('2026-01-01T00:00:00Z') : '2026-01-01T00:00:00.000Z' + await k('tx_labels').insert( + ids.map(id => ({ + txLabelId: id, + userId: input.userId, + label: 'gc process loss ' + id, + isDeleted: false, + created_at: date, + updated_at: date + })) + ) + await k('tx_labels').whereIn('txLabelId', ids).delete() + const floor = await newest(k) + await maintain(k, input, { kind: 'floor', floor }) + const key = { + tableId: 3, + ...(stream === 'scope' ? { userId: input.userId } : {}), + id1: ids[0] - 1, + id2: 0, + exactText: '' + } + const request = { epoch: input.epoch, floor, stream, limit: 3, after: { epoch: input.epoch, floor, stream, key } } + const table = 'snapshot_journal_' + stream, + order = + stream === 'scope' ? ['tableId', 'userId', 'id1', 'id2', 'exactText'] : ['tableId', 'id1', 'id2', 'exactText'] + const before = await k(table).orderBy(order), + beforeSource = await source(k), + beforeClock = input.backend === 'sqlite' ? await clock() : undefined + const deleted = row => + Number(row.tableId) === 3 && + ids.includes(Number(row.id1)) && + (stream !== 'scope' || Number(row.userId) === input.userId) + assert.equal(before.filter(deleted).length, 3) + assert(before.filter(deleted).every(row => Number(row.present) === 0)) + await killAt({ ...input, operation: stream, phase, request, marker: join(input.directory, stream + '-' + phase) }) + assert.deepEqual( + await k(table).orderBy(order), + phase === 'after-commit' ? before.filter(row => !deleted(row)) : before + ) + if (input.backend === 'sqlite') + assert.equal(await clock(), phase === 'after-commit' ? String(BigInt(beforeClock) + 1n) : beforeClock) + assert.deepEqual(await source(k), beforeSource) + const recovered = await maintain(k, input, { kind: 'collect', page: request }) + assert.equal(recovered.removed, phase === 'after-commit' ? 0 : 3) + assert(recovered.examined <= 3) + await exact(k) + assert.deepEqual(await source(k), beforeSource) + await k('tx_labels') + .where('txLabelId', 1) + .update({ label: 'writer after ' + stream + ' ' + phase }) + await exact(k) + results.push({ + operation: stream, + phase, + atomic: true, + sourcePreserved: true, + recovered: true, + writerProgress: true + }) + }) + }) + return results +} diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceFixture.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceFixture.cjs new file mode 100644 index 000000000..cf09456b5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceFixture.cjs @@ -0,0 +1,301 @@ +const assert = require('node:assert/strict') +const { mkdtemp, rm } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { + maintainSnapshotJournal: maintain +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalMaintenance.js') +const native = require('./snapshotJournalNativeFixture.cjs') +const { runInSeries } = require('../../out/src/utility/runInSeries.js') +const { + copySnapshotJournalBootstrapPage +} = require('../../out/src/storage/snapshot/journal/SnapshotJournalBootstrap.js') +const sqliteGeneration = require('../../out/src/storage/snapshot/journal/SnapshotJournalSqliteGeneration.js') +const mysqlGeneration = require('../../out/src/storage/snapshot/journal/SnapshotJournalMysqlGeneration.js') +const backend = require('../../out/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.js') +const identity = '02' + '11'.repeat(32) +const policy = { receiptLimit: 128, receiptLifetimeMs: 600000 }, + ceiling = '9223372036854775807' +function gate() { + let resolve + const promise = new Promise(yes => { + resolve = yes + }) + return { promise, resolve } +} +module.exports = async function proof(isolation) { + const local = isolation === 'WAL', + directory = await mkdtemp(join(tmpdir(), 'ts569-maintenance-controller-')) + const mysql = local ? undefined : require('./snapshotJournalMysqlConnection.cjs') + const config = { + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + } + const k = local ? native.knex(config) : mysql.open(true) + const peer = local ? native.knex(config) : mysql.open(true) + const { StorageKnex: Provider } = require('../../out/src/storage/StorageKnex.js') + const storage = new Provider({ ...native.StorageProvider.createStorageBaseOptions('test'), knex: k }) + const results = [], + pools = [], + originalClose = backend.closeSnapshotJournalCapturePool, + originalBind = backend.bindSnapshotJournalCaptureBackend + backend.closeSnapshotJournalCapturePool = async (...args) => { + await originalClose(...args) + pools.push(args[1]) + } + const request = (epoch, operation) => ({ epoch, ceiling, receiptPolicy: policy, operation }) + const run = async input => { + const value = await storage.maintainSnapshotJournal(input) + await storage.awaitSnapshotJournalMaintenanceCleanup() + return value + } + try { + if (local) { + await k.raw('PRAGMA journal_mode=WAL') + await k.raw('PRAGMA busy_timeout=0') + await peer.raw('PRAGMA busy_timeout=0') + } else { + await k.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + await peer.raw('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation) + k.client.config.pool = { + ...k.client.config.pool, + afterCreate(connection, done) { + connection.query('SET SESSION TRANSACTION ISOLATION LEVEL ' + isolation, error => done(error, connection)) + } + } + backend.bindSnapshotJournalCaptureBackend = async (...args) => { + const value = await originalBind(...args) + const [[write]] = await args[0].raw('SELECT @@transaction_isolation isolationLevel').connection(args[1]) + const [[read]] = await args[2].raw('SELECT @@transaction_isolation isolationLevel').connection(args[3]) + assert.equal(write.isolationLevel, isolation.replaceAll(' ', '-')) + assert.equal(read.isolationLevel, isolation.replaceAll(' ', '-')) + return value + } + } + await storage.migrate('owned bounded journal maintenance', 'synthetic-maintenance-controller') + await storage.makeAvailable() + const { user } = await storage.findOrInsertUser(identity), + { user: other } = await storage.findOrInsertUser('03' + '22'.repeat(32)) + await native.seedArchiveClosure(storage, user.userId, other.userId) + if (local) await sqliteGeneration.installSnapshotJournalSqliteGeneration(k, ceiling, policy) + else await mysqlGeneration.installSnapshotJournalMysqlGeneration(k, ceiling, policy) + let done = false + function* pages() { + for (let n = 0; n < 100 && !done; n++) yield n + } + await runInSeries(pages(), async () => { + done = (await copySnapshotJournalBootstrapPage(k, 1000000)).complete + }) + assert(done) + const generation = local + ? await sqliteGeneration.completeSnapshotJournalSqliteGeneration(k, policy) + : await mysqlGeneration.completeSnapshotJournalMysqlGeneration(k, ceiling, policy) + const epoch = generation.epoch + const date = local ? '2026-01-01T00:00:00.000Z' : new Date('2026-01-01T00:00:00.000Z') + await k('tx_labels').insert({ + txLabelId: 999, + userId: user.userId, + label: 'collectible', + isDeleted: false, + created_at: date, + updated_at: date + }) + await k('tx_labels').where('txLabelId', 999).delete() + const revisionSql = + 'SELECT CAST(MAX(revision) AS ' + + (local ? 'TEXT' : 'CHAR') + + ') revision FROM (SELECT revision FROM snapshot_journal_scope UNION ALL SELECT revision FROM snapshot_journal_physical) AS revisions' + const [[row]] = local ? [await k.raw(revisionSql)] : await k.raw(revisionSql) + await native.exact(k) + const arrays = async () => { + const rows = [] + await runInSeries(native.tables, async table => { + rows.push((await k(table)).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))) + }) + return rows + } + const before = await arrays() + const floor = await run(request(epoch, { kind: 'floor', floor: row.revision })) + assert.equal(floor.kind, 'floor') + assert.equal(floor.value.floor, row.revision) + results.push('owned complete generation advances floor and physically closes both pools') + await runInSeries(['scope', 'physical'], async stream => { + let after, + finished = false, + examined = 0, + removed = 0 + function* pages() { + for (let n = 0; n < 100 && !finished; n++) yield n + } + await runInSeries(pages(), async () => { + const answer = await run( + request(epoch, { kind: 'collect', page: { epoch, floor: row.revision, stream, limit: 4, after } }) + ) + assert.equal(answer.kind, 'collect') + assert(answer.value.examined <= 4) + examined += answer.value.examined + removed += answer.value.removed + after = answer.value.after + finished = answer.value.complete + }) + assert(finished) + assert(examined > 4) + assert.equal(removed, 1) + }) + await native.exact(k) + assert.deepEqual(await arrays(), before) + results.push('bounded multi-page collection preserves all thirteen source arrays') + const stale = request('00000000-0000-4000-8000-000000000000', { kind: 'floor', floor: row.revision }) + const rejected = maintain(async () => k.client.config, stale) + await assert.rejects(rejected.result, /generation/) + await assert.rejects(rejected.closed, /generation/) + assert.deepEqual(await arrays(), before) + results.push('changed generation refuses without source mutation') + const lock = await peer.transaction() + try { + await lock('knex_migrations_lock').where('index', 1).update({ is_locked: 1 }) + const rejected = maintain(async () => k.client.config, request(epoch, { kind: 'floor', floor: row.revision })) + const start = performance.now() + await assert.rejects(rejected.result) + await assert.rejects(rejected.closed) + assert(performance.now() - start < 2000) + } finally { + await lock.rollback() + } + results.push('actual held migration owner refuses boundedly') + if (!local) { + const entered = gate(), + release = gate(), + read = mysqlGeneration.readSnapshotJournalMysqlGeneration + mysqlGeneration.readSnapshotJournalMysqlGeneration = async (...args) => { + entered.resolve() + await release.promise + return await read(...args) + } + const task = maintain(async () => k.client.config, request(epoch, { kind: 'floor', floor: row.revision })) + try { + await entered.promise + const start = performance.now() + await peer('tx_labels').where('txLabelId', 1).update({ label: 'foreground during generation verification' }) + assert(performance.now() - start < 2000) + release.resolve() + await task.result + await task.closed + results.push('foreground wallet write proceeds during actual controller generation validation') + } finally { + release.resolve() + mysqlGeneration.readSnapshotJournalMysqlGeneration = read + await task.closed.catch(() => {}) + } + } + assert(pools.length >= 6) + assert(pools.every(connection => (local ? connection.open === false : connection.stream.closed))) + const cuts = await require('./snapshotJournalMaintenanceCuts.cjs')(k, { + backend: local ? 'sqlite' : 'mysql', + filename: config.connection.filename, + isolation, + directory, + userId: user.userId, + epoch, + ceiling, + receiptPolicy: policy + }) + assert.equal(cuts.length, 8) + results.push({ processLoss: cuts }) + row.revision = String((await k('snapshot_journal_retention').first()).floor) + const entered = gate(), + release = gate(), + controller = new AbortController() + const receipts = require('../../out/src/storage/snapshot/journal/SnapshotJournalReceipt.js'), + advance = receipts.advanceSnapshotJournalFloor + const prior = await k('snapshot_journal_retention').first() + receipts.advanceSnapshotJournalFloor = async (...args) => { + const value = await advance(...args) + entered.resolve() + await release.promise + return value + } + try { + const pending = storage.maintainSnapshotJournal(request(epoch, { kind: 'floor', floor: row.revision }), { + signal: controller.signal + }) + const rejection = assert.rejects(pending, /cancelled/) + await entered.promise + controller.abort() + await rejection + await assert.rejects( + storage.maintainSnapshotJournal(request(epoch, { kind: 'floor', floor: row.revision })), + /already/ + ) + await assert.rejects(storage.openSnapshotJournalSource(identity, { ceiling, receiptPolicy: policy }), /already/) + let closed = false + const cleanup = storage.awaitSnapshotJournalMaintenanceCleanup().then(() => { + closed = true + }) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(closed, false) + release.resolve() + await cleanup + assert.equal((await k('snapshot_journal_retention').first()).floor, prior.floor) + results.push('provider cancellation retains shared source admission until rollback and physical cleanup') + } finally { + release.resolve() + receipts.advanceSnapshotJournalFloor = advance + } + await run(request(epoch, { kind: 'floor', floor: row.revision })) + const resolve = storage.concurrentSnapshotReaderConfig.bind(storage), + enteredConfig = gate(), + releaseConfig = gate(), + controller2 = new AbortController() + storage.concurrentSnapshotReaderConfig = async () => { + enteredConfig.resolve() + await releaseConfig.promise + return await resolve() + } + const pending = storage.maintainSnapshotJournal(request(epoch, { kind: 'floor', floor: row.revision }), { + signal: controller2.signal + }) + const rejection = assert.rejects(pending) + await enteredConfig.promise + let destroyed = false + const destroying = storage.destroy().then(() => { + destroyed = true + }) + await rejection + await new Promise(resolve => setImmediate(resolve)) + assert.equal(destroyed, false) + await assert.rejects( + storage.maintainSnapshotJournal(request(epoch, { kind: 'floor', floor: row.revision })), + /destruction/ + ) + releaseConfig.resolve() + await destroying + results.push( + 'provider destruction fences admission synchronously and drains pending configuration without allocating' + ) + console.log( + JSON.stringify({ + fixture: 'owned bounded journal maintenance', + isolation, + results, + nativePoolsClosed: pools.length, + limitations: [ + 'no runtime quotas or registered generation lifecycle qualification', + 'component qualification does not establish full program or production readiness' + ] + }) + ) + } finally { + backend.closeSnapshotJournalCapturePool = originalClose + backend.bindSnapshotJournalCaptureBackend = originalBind + await Promise.allSettled([storage.destroy(), peer.destroy()]) + await rm(directory, { recursive: true, force: true }) + } +} +if (require.main?.filename === __filename) + module.exports('WAL').catch(error => { + console.error(error) + process.exitCode = 1 + }) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceMysqlRc.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceMysqlRc.cjs new file mode 100644 index 000000000..2be420c6a --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceMysqlRc.cjs @@ -0,0 +1,4 @@ +require('./snapshotJournalMaintenanceFixture.cjs')('READ COMMITTED').catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceMysqlRr.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceMysqlRr.cjs new file mode 100644 index 000000000..070ce4bbe --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceMysqlRr.cjs @@ -0,0 +1,4 @@ +require('./snapshotJournalMaintenanceFixture.cjs')('REPEATABLE READ').catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceProcessLoss.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceProcessLoss.cjs new file mode 100644 index 000000000..144b0bab1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceProcessLoss.cjs @@ -0,0 +1,67 @@ +const assert = require('node:assert/strict') +const { fork } = require('node:child_process') +const { writeFileSync } = require('node:fs') +const { open } = require('node:fs/promises') +const { join } = require('node:path') + +// These hooks belong only to the disposable fixture's own native pool. +function inject(k, operation, phase) { + assert(['floor', 'scope', 'physical'].includes(operation)) + assert(['after-first-delete', 'before-commit', 'after-commit'].includes(phase)) + assert(operation !== 'floor' || phase !== 'after-first-delete') + const prefix = + operation === 'floor' ? 'update `snapshot_journal_retention`' : 'delete from `snapshot_journal_' + operation + '`' + let changed = false + const park = () => { + writeFileSync(4, phase) + process.kill(process.pid, 'SIGKILL') + } + k.on('query', q => { + if (q.sql.startsWith(prefix)) changed = true + if (changed && q.sql === 'COMMIT;' && phase === 'before-commit') park() + }) + k.on('query-response', (_response, q) => { + if (q.sql.startsWith(prefix) && phase === 'after-first-delete') park() + }) + const transaction = k.client.transaction + k.client.transaction = function (...parameters) { + const trx = transaction.apply(this, parameters) + const commit = trx.commit + trx.commit = async function (...values) { + const result = await commit.apply(this, values) + if (changed && phase === 'after-commit') { + await this.transactor.executionPromise + park() + } + return result + } + return trx + } +} +async function killAt(input) { + const { marker, ...childInput } = input + const output = await open(marker, 'wx+', 0o600) + const child = fork(join(__dirname, 'snapshotJournalMaintenanceChild.cjs'), [JSON.stringify(childInput)], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc', output.fd] + }) + let stderr = '' + child.stderr.on('data', data => { + stderr += data + }) + const timer = setTimeout(() => child.kill('SIGKILL'), 20000) + try { + const result = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + assert.equal(result.signal, 'SIGKILL', stderr) + const bytes = Buffer.alloc(64) + const { bytesRead } = await output.read(bytes, 0, bytes.length, 0) + assert.equal(bytes.subarray(0, bytesRead).toString('utf8'), input.phase) + } finally { + clearTimeout(timer) + await output.close() + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + } +} +module.exports = { inject, killAt } diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceWal.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceWal.cjs new file mode 100644 index 000000000..a4e5fb86f --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalMaintenanceWal.cjs @@ -0,0 +1,4 @@ +require('./snapshotJournalMaintenanceFixture.cjs')('WAL').catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs index 79022ccdb..13bb02326 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotJournalSqliteCrash.cjs @@ -192,6 +192,7 @@ async function main() { ) await require('./snapshotJournalCaptureSqlite.cjs')() await require('./snapshotJournalRetentionSqlite.cjs')() + await require('./snapshotJournalMaintenanceFixture.cjs')('WAL') console.log( JSON.stringify({ status: 'SQLite journal generation native WAL process-loss checks', @@ -200,7 +201,7 @@ async function main() { limitations: [ 'not yet a registered forward migration', 'no MySQL implicit-DDL lifecycle qualification', - 'provider floor ownership/quota/receiver and platform-scale acceptance remain open' + 'runtime quotas/registered lifecycle/receiver and platform-scale acceptance remain open' ] }) ) diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index 2cc964eed..b20e03f75 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -63,7 +63,10 @@ const plans = new Map([ ['src/storage/snapshot/journal/SnapshotJournalConnections.ts', 'connections'], ['src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', 'capture-backend'], ['src/storage/snapshot/journal/SnapshotJournalCapture.ts', 'capture'], - ['src/storage/snapshot/journal/SnapshotJournalCollection.ts', 'collection'] + ['src/storage/snapshot/journal/SnapshotJournalCollection.ts', 'collection'], + ['src/storage/snapshot/journal/SnapshotJournalMaintenance.ts', 'maintenance'], + ['src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.ts', 'maintenance-fence'], + ['src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.ts', 'maintenance-task'] ]) } ], diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index e28d1b81a..40095dab6 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -299,7 +299,10 @@ for (const [id, expected, fallback] of [ 'connections', 'capture-backend', 'capture', - 'collection' + 'collection', + 'maintenance', + 'maintenance-fence', + 'maintenance-task' ], 'revision' ], diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 66279ed44..566808719 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -276,7 +276,10 @@ test('journal mutation registration retains its complete source, canonical tests 'src/storage/snapshot/journal/SnapshotJournalConnections.ts', 'src/storage/snapshot/journal/SnapshotJournalCaptureBackend.ts', 'src/storage/snapshot/journal/SnapshotJournalCapture.ts', - 'src/storage/snapshot/journal/SnapshotJournalCollection.ts' + 'src/storage/snapshot/journal/SnapshotJournalCollection.ts', + 'src/storage/snapshot/journal/SnapshotJournalMaintenance.ts', + 'src/storage/snapshot/journal/SnapshotJournalMaintenanceFence.ts', + 'src/storage/snapshot/journal/SnapshotJournalMaintenanceTask.ts' ]) assert.deepEqual(target.additionalInputs, [ 'test/fixtures/snapshotJournal/mysql-generation-ddl-fixture.json', @@ -303,6 +306,13 @@ test('journal mutation registration retains its complete source, canonical tests 'test/storage/snapshotJournalRetentionMysqlRr.cjs', 'test/storage/snapshotJournalRetentionProcessLoss.cjs', 'test/storage/snapshotJournalRetentionSqlite.cjs', + 'test/storage/snapshotJournalMaintenanceFixture.cjs', + 'test/storage/snapshotJournalMaintenanceCuts.cjs', + 'test/storage/snapshotJournalMaintenanceChild.cjs', + 'test/storage/snapshotJournalMaintenanceProcessLoss.cjs', + 'test/storage/snapshotJournalMaintenanceWal.cjs', + 'test/storage/snapshotJournalMaintenanceMysqlRc.cjs', + 'test/storage/snapshotJournalMaintenanceMysqlRr.cjs', 'test/storage/snapshotJournalSqliteCrash.cjs', 'test/storage/runSnapshotJournalMysql.cjs', 'test/storage/snapshotArchiveDocker.cjs' @@ -318,3 +328,23 @@ test('journal mutation registration retains its complete source, canonical tests ]).includes('wallet-snapshot-journal') ) }) + +test('every inherited snapshot-sync target keeps the complete journal tests for owned provider ranges', () => { + const targets = buildMutationTargets(REPOSITORY_ROOT) + for (const name of [ + 'wallet-snapshot-sync', + 'wallet-snapshot-sync-destination', + 'wallet-snapshot-sync-rows' + ]) { + assert.ok( + targets[name].runnerOptions.jest.config.testMatch.includes( + '/src/storage/snapshot/journal/*.test.ts' + ) + ) + assert.ok( + targets[name].runnerOptions.jest.config.testMatch.includes( + '/src/storage/snapshot/SnapshotSync*.test.ts' + ) + ) + } +}) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index b9a0145bc..a5972dc4f 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -307,3 +307,29 @@ Provider maintenance integration, runtime quotas and registered forward lifecycl and recovery remain required within S2. No public reader advertisement or migration registration is introduced by this checkpoint. Every acceptance row remains open until its complete implementation and end-to-end evidence exist. + +## Owned bounded journal maintenance checkpoint + +The internal provider controller now owns complete generation validation and +configured migration-owner exclusion before each bounded floor or tombstone-page +transaction. MySQL permits foreground writes during generation validation and +reserves the global clock only for the bounded operation; SQLite obtains the WAL +writer before generation reads. Admission is shared with retained source capture. +Cancellation/result expiry retains that admission through native drain, and +provider destruction fences new work synchronously. Cleanup failures preserve +operation and native causes and stop further retained admission. + +Unit fault tests, 300 seeded lifecycle schedules and native WAL/RC/RR proofs +exercise commit, partial deletion, lost acknowledgement, cancellation, shutdown +and preservation of all thirteen source arrays. The journal mutation registration +now owns twenty-two complete modules in twenty parts, while every part retains +the complete journal test union. The retained-provider test union also includes +the complete maintenance suites. Each inherited snapshot-sync target retains the +complete journal test union for its owned provider ranges. Policies, thresholds +and deadlines are preserved. + +This advances the internal maintenance portion of S2. Runtime quotas, registered +forward generation lifecycle/recovery, payload/receiver integration and all other +#544 acceptance requirements remain open. It adds no migration, advertised +capability or complete-program readiness claim. Raw operator DDL must remain +outside maintenance unless separately excluded by the operator. From 66f3001f962eb769984af70d1e37b07a7a02a0d0 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 17:53:37 -0700 Subject: [PATCH 097/127] test(wallet): validate journal fixtures inside test setup --- .../snapshot/journal/SnapshotJournalCapture.test.ts | 13 ++++++++----- .../journal/SnapshotJournalMaintenance.test.ts | 7 +++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts index 31732171b..144983f51 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts @@ -13,17 +13,20 @@ import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' import { readSnapshotJournalReceipt } from './SnapshotJournalReceipt' import { snapshotJournalRevision } from './SnapshotJournalRevision' import { SnapshotJournalConnectionCleanupError } from './SnapshotJournalConnections' -import type { SnapshotJournalSource } from './SnapshotJournalCapture' +import type { SnapshotJournalCaptureRequest, SnapshotJournalSource } from './SnapshotJournalCapture' import * as Closure from '../archive/KnexSnapshotArchiveClosure' import * as Backend from './SnapshotJournalCaptureBackend' import * as Receipts from './SnapshotJournalReceipt' import { snapshotArchiveTables } from '../archive/KnexSnapshotArchiveStore' const identity = '02' + '11'.repeat(32) -const request = { - ceiling: snapshotJournalRevision('1000000'), - receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } -} +let request: SnapshotJournalCaptureRequest +beforeEach(() => { + request = { + ceiling: snapshotJournalRevision('1000000'), + receiptPolicy: { receiptLimit: 128, receiptLifetimeMs: 600000 } + } +}) function gate() { let resolve!: () => void const promise = new Promise(yes => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts index 6e4d5e250..979c8a786 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts @@ -6,7 +6,7 @@ import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' import { maintainSnapshotJournal, type SnapshotJournalMaintenanceRequest } from './SnapshotJournalMaintenance' -import { snapshotJournalRevision } from './SnapshotJournalRevision' +import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' import * as Sqlite from './SnapshotJournalSqliteGeneration' import * as Mysql from './SnapshotJournalMysqlGeneration' import * as Backend from './SnapshotJournalCaptureBackend' @@ -15,8 +15,11 @@ import * as Fence from './SnapshotJournalMaintenanceFence' import * as Receipts from './SnapshotJournalReceipt' import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' -const identity = '02' + '11'.repeat(32), +const identity = '02' + '11'.repeat(32) +let ceiling: SnapshotJournalRevision +beforeEach(() => { ceiling = snapshotJournalRevision('1000000') +}) const receiptPolicy = { receiptLimit: 128, receiptLifetimeMs: 600000 } const epoch = '00000000-0000-4000-8000-000000000000' const input = (id = epoch): SnapshotJournalMaintenanceRequest => ({ From a7ceefb86894f51aa678a9375c8b3d13472b367f Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 20:17:45 -0700 Subject: [PATCH 098/127] fix(wallet): preserve capture failures and repair Metro watcher dependency --- docs/reference/dependency-policy.md | 19 + docs/reference/package-api-migrations.md | 2 +- governance/dependency-release-policy.json | 14 +- governance/package-release-notes.json | 2 +- governance/repository-health/exceptions.json | 19 + packages/wallet/wallet-toolbox/README.md | 4 +- .../journal/SnapshotJournalCapture.test.ts | 449 +++++ .../journal/SnapshotJournalCapture.ts | 10 +- .../SnapshotJournalCaptureBackend.test.ts | 132 +- .../SnapshotJournalCaptureFence.test.ts | 184 ++ .../SnapshotJournalConnections.test.ts | 76 +- .../SnapshotJournalMaintenance.test.ts | 95 + patches/metro-file-map@0.87.1.patch | 84 + pnpm-lock.yaml | 53 +- pnpm-workspace.yaml | 12 + scripts/check-wallet-toolbox-platform.mjs | 5 + .../dependency-release-governance.test.mjs | 6 +- .../lib/fixtures/metro-watcher-contract.json | 1702 +++++++++++++++++ scripts/lib/metro-watcher-contract.mjs | 59 + 19 files changed, 2867 insertions(+), 60 deletions(-) create mode 100644 patches/metro-file-map@0.87.1.patch create mode 100644 scripts/lib/fixtures/metro-watcher-contract.json create mode 100644 scripts/lib/metro-watcher-contract.mjs diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index e76682fd3..26bdbaa77 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -120,6 +120,25 @@ public APIs or candidate versions. Frozen installation, root checks, docs tests and a built-site browser check qualify the ordinary Mermaid consumer. No service or package is deployed by this source change. +## Temporary Metro watcher dependency repair + +Metro-file-map 0.87.1 uses only micromatch.some(), whose matcher is already +Picomatch 2.3.2. The exact-version paired source/distribution patch calls that +same loop directly, declares the same exact Picomatch dependency and removes +only the scoped micromatch dependency and its now-unused braces closure. +The reviewed [braces advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) +lists no patched release; the [upstream proposal](https://github.com/micromatch/braces/pull/72) +is still unreleased. No advisory exclusion or audit threshold change is added. + +The existing mobile platform gate checks 1,120 watcher results frozen from the +unmodified published implementation, event/stat/path contracts, and absence of +the removed matcher packages before its packed Metro/Hermes compilation, +source-map/composition and unchanged bundle budgets. The dated override registry +owns the patch, exact package extension and scoped removal as one repair. Remove +all three together when an official compatible release removes the affected +path and the full compatibility, frozen graph, audit and platform checks pass. +This build-tool repair does not change published wallet APIs or package versions. + ## Supply-chain controls `pnpm-workspace.yaml` is the source of truth for installation controls: diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index fb6318842..3119c05c2 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -514,7 +514,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. - Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index 3dd19e9a6..696e9b955 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -145,10 +145,10 @@ }, "overrideRemovalReview": { "reviewedAt": "2026-10-01", - "method": "Rechecked every registered substitution after the workspace brace-expansion floor moved from 5.0.9 to 5.0.12 and engine.io 6.6.10 was added. Jest and Stryker minimatch still admit brace-expansion below 5.0.12, and socket.io in authsocket still admits engine.io below 6.6.10. Both substitutions stay on the first release that clears the current high advisories. The other 23 selectors, including the Metro-scoped image-size 2.0.4 substitution, remain necessary against the frozen graph. On 2026-10-02 the lodash-es 4.18.1 selector was added because Mermaid 12 brings chevrotain 11.1.2, which pins vulnerable lodash-es 4.17.23. The yamux-scoped @libp2p/utils 7.4.1 selector was added so YamuxStream satisfies @libp2p/interface 3.3 readableEnded. Metro 0.87.1 replaced its image-size dependency with an in-tree parser, so the Metro-scoped image-size substitution was retired on 2026-10-02. Reconciliation with #569 retains its previously qualified 5.0.12 floor for GHSA-q2hr-2g5m-vwhr in both workspace and standalone graphs.", - "retainedCount": 26, - "result": "All 26 retained overrides prevent a reproduced vulnerable transitive version, keep a stream type assignable, or preserve an isolated reproducible toolchain closure. The workspace brace-expansion selector is now 5.0.12, covering GHSA-6j4f-fj2g-mc7p and GHSA-qhr7-859c-m2p7. The engine.io 6.6.10 selector closes GHSA-2gc4-cqfq-p2gv on the authsocket socket.io path without an advisory exclusion. The lodash-es 4.18.1 selector closes GHSA-r5fr-rjxr-66jc on the Mermaid/chevrotain path. The yamux-scoped @libp2p/utils 7.4.1 selector supplies readableEnded for interface 3.3. The brace-expansion floor also covers GHSA-q2hr-2g5m-vwhr; no advisory exclusion is introduced.", - "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch/brace-expansion/js-yaml, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO/engine.io, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, image-size, or Mermaid/chevrotain/lodash-es dependency changes." + "method": "Rechecked every registered substitution after the workspace brace-expansion floor moved from 5.0.9 to 5.0.12 and engine.io 6.6.10 was added. Jest and Stryker minimatch still admit brace-expansion below 5.0.12, and socket.io in authsocket still admits engine.io below 6.6.10. Both substitutions stay on the first release that clears the current high advisories. The other 23 selectors, including the Metro-scoped image-size 2.0.4 substitution, remain necessary against the frozen graph. On 2026-10-02 the lodash-es 4.18.1 selector was added because Mermaid 12 brings chevrotain 11.1.2, which pins vulnerable lodash-es 4.17.23. The yamux-scoped @libp2p/utils 7.4.1 selector was added so YamuxStream satisfies @libp2p/interface 3.3 readableEnded. Metro 0.87.1 replaced its image-size dependency with an in-tree parser, so the Metro-scoped image-size substitution was retired on 2026-10-02. Reconciliation with #569 retains its previously qualified 5.0.12 floor for GHSA-q2hr-2g5m-vwhr in both workspace and standalone graphs. On 2026-10-02, no released Metro/file-map/micromatch/braces upgrade removes high GHSA-vfj7-8cjw-p6xm. The exact Metro-file-map0.87.1 watcher patch uses its existing Picomatch2.3.2 some() matcher and removes only scoped micromatch and its unused braces closure; all importers/settings and other package resolutions remain unchanged.", + "retainedCount": 27, + "result": "All 27 retained overrides prevent a reproduced vulnerable transitive version, keep a stream type assignable, or preserve an isolated reproducible toolchain closure. The workspace brace-expansion selector is now 5.0.12, covering GHSA-6j4f-fj2g-mc7p and GHSA-qhr7-859c-m2p7. The engine.io 6.6.10 selector closes GHSA-2gc4-cqfq-p2gv on the authsocket socket.io path without an advisory exclusion. The lodash-es 4.18.1 selector closes GHSA-r5fr-rjxr-66jc on the Mermaid/chevrotain path. The yamux-scoped @libp2p/utils 7.4.1 selector supplies readableEnded for interface 3.3. The brace-expansion floor also covers GHSA-q2hr-2g5m-vwhr; no advisory exclusion is introduced. The Metro-file-map exact-version repair removes the affected dependency closure without an audit exclusion; source-owned watcher compatibility, packed Metro/Hermes and complete affected gates remain required.", + "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch/brace-expansion/js-yaml, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO/engine.io, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, image-size, or Mermaid/chevrotain/lodash-es dependency changes. Remove the paired Metro watcher patch, exact package extension and scoped removal together once an official compatible release removes the affected closure and all watcher/platform gates pass." }, "overrideRegistry": [ { @@ -311,6 +311,12 @@ "selector": "gaxios", "value": "7.3.0", "exceptionId": "standalone-gaxios-advisory-override" + }, + { + "source": "pnpm-workspace.yaml", + "selector": "metro-file-map@0.87.1>micromatch", + "value": "'-'", + "exceptionId": "metro-file-map-picomatch-advisory-repair" } ], "scheduledVerification": { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 266d97fd5..7c400dfd5 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,7 +210,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources.", + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup.", "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced." }, { diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index 1cafa8125..e8b08c55e 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -414,6 +414,25 @@ "created": "2026-10-02", "reviewBy": "2026-11-02", "removeWhen": "Remove when a released @stryker-mutator/vitest-runner supports Vitest 5 test-name chains, the governed Stryker pin is moved to it, and fund-wallet-cli and payment-402 meet their policy floors on Vitest 5 with zero survived mutants reporting testsCompleted 0." + }, + { + "id": "metro-file-map-picomatch-advisory-repair", + "category": "override", + "target": "pnpm-workspace.yaml exact metro-file-map@0.87.1>micromatch removal, Picomatch2.3.2 package extension and paired watcher patch", + "owner": "ts-stack-maintainers", + "reason": "No patched braces release or compatible Metro/file-map/micromatch upgrade is available for high GHSA-vfj7-8cjw-p6xm. Metro-file-map0.87.1 only consumes micromatch.some(), which already delegates to Picomatch2.3.2. The source/distribution patch retains that exact loop and adds the same exact direct matcher dependency, permitting scoped removal of micromatch and its unused vulnerable braces closure. All other package versions/importers and installation controls are preserved. This is a temporary dependency repair, not an advisory exclusion.", + "evidence": [ + "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm", + "https://github.com/micromatch/braces/pull/72", + "patches/metro-file-map@0.87.1.patch", + "scripts/lib/metro-watcher-contract.mjs", + "scripts/lib/fixtures/metro-watcher-contract.json", + "scripts/check-wallet-toolbox-platform.mjs", + "https://github.com/bsv-blockchain/ts-stack/discussions/757" + ], + "created": "2026-10-02", + "reviewBy": "2026-11-01", + "removeWhen": "Remove the patch, exact-version package extension and scoped removal together after an official compatible Metro/file-map release removes the affected dependency path; require the complete watcher oracle, fresh unexcluded audit, frozen graph and packed Metro/Hermes platform checks before retirement." } ] } diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 89655690a..02958ccaf 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -69,7 +69,9 @@ and generation, persists the receipt, and publishes after receipt commit and closure verification. It requires an existing file-backed WAL database with `better-sqlite3` or a static `mysql2` connection; other drivers refuse before pool construction. Physical cleanup retains provider admission, and an unproved close -fences further sources. Run `pnpm test:snapshot-journal-crash` for the +fences further sources. If source work and transaction cleanup both fail, the +ownership error retains the original source failure and each rollback cause. +Run `pnpm test:snapshot-journal-crash` for the SQLite process-loss fixture and `pnpm test:snapshot-journal-mysql` for the isolated MySQL client/server-process recovery fixtures. Internal floor transactions reserve the global writer clock before current receipt locks and cannot pass a live diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts index 144983f51..e6872310f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.test.ts @@ -13,11 +13,21 @@ import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' import { readSnapshotJournalReceipt } from './SnapshotJournalReceipt' import { snapshotJournalRevision } from './SnapshotJournalRevision' import { SnapshotJournalConnectionCleanupError } from './SnapshotJournalConnections' +import * as Connections from './SnapshotJournalConnections' import type { SnapshotJournalCaptureRequest, SnapshotJournalSource } from './SnapshotJournalCapture' +import * as Capture from './SnapshotJournalCapture' import * as Closure from '../archive/KnexSnapshotArchiveClosure' import * as Backend from './SnapshotJournalCaptureBackend' import * as Receipts from './SnapshotJournalReceipt' import { snapshotArchiveTables } from '../archive/KnexSnapshotArchiveStore' +import { WERR_INVALID_PARAMETER } from '../../../sdk/WERR_errors' +import { SnapshotResourceLimitError } from '../SnapshotResourceLimitError' +import * as Archive from '../archive/KnexSnapshotArchiveSource' +import * as MysqlGeneration from './SnapshotJournalMysqlGeneration' +import * as Fence from './SnapshotJournalCaptureFence' +import * as ArchiveSql from '../archive/SnapshotArchiveSql' +import { createHash } from 'node:crypto' +import { Duplex } from 'node:stream' const identity = '02' + '11'.repeat(32) let request: SnapshotJournalCaptureRequest @@ -318,7 +328,10 @@ test('provider destruction drains an active captured view before closing the for const f = await fixture() try { const view = await f.storage.openSnapshotJournalSource(identity, request) + const detach = jest.spyOn(f.k, 'off') await f.storage.destroy() + for (const event of ['query', 'query-response', 'query-error']) + expect(detach).toHaveBeenCalledWith(event, expect.any(Function)) expect(view.isOpen).toBe(false) await view.closed await expect(view.readPage('txLabels')).rejects.toThrow('closed') @@ -328,9 +341,57 @@ test('provider destruction drains an active captured view before closing the for } }) +test('destruction fences a pending source configuration callback before database access', async () => { + const f = await fixture(), + entered = gate(), + release = gate(), + retain = Capture.retainSnapshotJournalCapture, + acquire = jest.spyOn(f.k.client, 'acquireConnection') + let refused: unknown + jest.spyOn(Capture, 'retainSnapshotJournalCapture').mockImplementation((chain, configure, ...rest) => + retain( + chain, + async () => { + entered.resolve() + await release.promise + try { + return await configure() + } catch (error) { + refused = error + throw error + } + }, + ...rest + ) + ) + try { + const opening = f.storage.openSnapshotJournalSource(identity, request), + outcome = opening.then( + value => ({ value }), + error => ({ error }) + ) + await entered.promise + const destruction = f.storage.destroy().then( + value => ({ value }), + error => ({ error }) + ) + release.resolve() + const destroyed = await destruction + expect(refused).toMatchObject({ message: 'Snapshot journal sources are unavailable after destruction begins' }) + expect(destroyed).toHaveProperty('error', refused) + expect(await outcome).toHaveProperty('error') + expect(acquire).not.toHaveBeenCalled() + } finally { + release.resolve() + jest.restoreAllMocks() + await f.close() + } +}) + test('public input is detached before asynchronous setup and invalid input does not reserve admission', async () => { const f = await fixture() try { + await expect(f.storage.awaitSnapshotJournalCaptureCleanup()).resolves.toBeUndefined() await expect(f.storage.openSnapshotJournalSource('foreign', request)).rejects.toThrow('identityKey') const input = { ...request, receiptPolicy: { ...request.receiptPolicy } } const opening = f.storage.openSnapshotJournalSource(identity, input) @@ -358,6 +419,8 @@ test('source failure is retriable after physical cleanup; an unproved close fenc const config = jest.spyOn(f.k.client, 'acquireConnection').mockRejectedValue(fault) await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toBe(fault) config.mockRestore() + await expect(f.storage.awaitSnapshotJournalCaptureCleanup()).rejects.toBe(fault) + await expect(f.storage.awaitSnapshotJournalCaptureCleanup()).rejects.toBe(fault) await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('destruction') await expect(f.storage.destroy()).rejects.toBe(fault) } finally { @@ -415,3 +478,389 @@ test('a source failure and owned-provider cleanup failure remain observable toge await rm(f.directory, { recursive: true, force: true }) } }) + +test.each(['02' + 'ab'.repeat(32) + 'x', 'x02' + 'ab'.repeat(32), '04' + 'ab'.repeat(32), '', undefined, 1])( + 'invalid compressed identity %p refuses synchronously before configuration', + identityKey => { + const configure = jest.fn(async () => undefined) + expect(() => Capture.retainSnapshotJournalCapture('test', configure, identityKey as string, request)).toThrow( + WERR_INVALID_PARAMETER + ) + expect(() => Capture.retainSnapshotJournalCapture('test', configure, identityKey as string, request)).toThrow( + 'identityKey' + ) + expect(configure).not.toHaveBeenCalled() + } +) + +test('zero revision ceiling refuses synchronously before configuration', () => { + const configure = jest.fn(async () => undefined) + expect(() => + Capture.retainSnapshotJournalCapture('test', configure, identity, { + ...request, + ceiling: snapshotJournalRevision('0') + }) + ).toThrow('generation or profile') + expect(configure).not.toHaveBeenCalled() +}) + +test('an unavailable static configuration retains its exact unsupported-provider refusal through cleanup', async () => { + const lifetime = Capture.retainSnapshotJournalCapture('test', async () => undefined, identity, request) + const error = await lifetime.opened.catch(value => value) + expect(error).toMatchObject({ + name: 'WERR_NOT_IMPLEMENTED', + message: 'Snapshot journal capture requires file-backed SQLite WAL or static MySQL' + }) + await expect(lifetime.closed).rejects.toBe(error) +}) + +test.each(['disabled', 'exhausted'] as const)( + 'a native %s event clock commits refusal without a receipt or ordinary source failure', + async state => { + const f = await fixture() + try { + await f + .k('snapshot_journal_clock') + .update(state === 'disabled' ? { enabled: 0, reason: 'capacity-exhausted' } : { revision: request.ceiling }) + const error = await f.storage.openSnapshotJournalSource(identity, request).catch(value => value) + expect(error).toBeInstanceOf(SnapshotResourceLimitError) + expect(error.message).toBe('Snapshot journal event window is exhausted or disabled') + expect(await f.k('snapshot_journal_clock').first('enabled', 'reason')).toEqual({ + enabled: 0, + reason: 'capacity-exhausted' + }) + expect(await f.k('snapshot_journal_receipts')).toEqual([]) + await f.k('tx_labels').where('txLabelId', 1).update({ label: 'ordinary write after exhausted capture' }) + expect((await f.k('tx_labels').where('txLabelId', 1).first()).label).toBe( + 'ordinary write after exhausted capture' + ) + } finally { + await f.close() + } + } +) + +test('a mismatched generation ceiling rolls back its barrier and never publishes a receipt', async () => { + const f = await fixture() + try { + const before = await f.k('snapshot_journal_clock').first() + await expect( + f.storage.openSnapshotJournalSource(identity, { ...request, ceiling: snapshotJournalRevision('1000001') }) + ).rejects.toThrow('generation or profile') + expect(await f.k('snapshot_journal_clock').first()).toEqual(before) + expect(await f.k('snapshot_journal_receipts')).toEqual([]) + } finally { + await f.close() + } +}) + +test.each(['identity', 'chain'] as const)( + 'a pinned %s header mismatch refuses before receipt publication', + async field => { + const f = await fixture(), + read = Archive.readKnexSnapshotArchiveHeader + jest.spyOn(Archive, 'readKnexSnapshotArchiveHeader').mockImplementation(async (...args) => { + const result = await read(...args) + if (field === 'identity') result.header.user.identityKey = '03' + '22'.repeat(32) + else result.header.sourceStorage.chain = 'main' + return result + }) + try { + await expect(f.storage.openSnapshotJournalSource(identity, request)).rejects.toThrow('generation or profile') + expect(await f.k('snapshot_journal_receipts')).toEqual([]) + } finally { + jest.restoreAllMocks() + await f.close() + } + } +) + +test('a receipt failure and both actual transaction rollback failures retain all original causes after native drain', async () => { + const f = await fixture(), + connect = Connections.withSnapshotJournalConnections + const sourceError = new Error('receipt failed'), + rollbackErrors = [new Error('writer rollback failed'), new Error('reader rollback failed')] + jest.spyOn(Receipts, 'recordSnapshotJournalReceipt').mockRejectedValue(sourceError) + jest.spyOn(Connections, 'withSnapshotJournalConnections').mockImplementation(async (...args) => { + for (const [index, owner] of [args[0], args[1]].entries()) { + const transaction = owner.transaction.bind(owner) + Object.defineProperty(owner, 'transaction', { + ...Object.getOwnPropertyDescriptor(owner, 'transaction'), + writable: true + }) + jest.spyOn(owner, 'transaction').mockImplementation(async (...parameters: unknown[]) => { + const trx = await transaction(parameters[0] as Knex.TransactionConfig), + query = trx.client.query.bind(trx.client) + jest.spyOn(trx.client, 'query').mockImplementation(async (...queryParameters: unknown[]) => { + const result: unknown = await query(queryParameters[0], queryParameters[1]) + if (queryParameters[1] === 'ROLLBACK') throw rollbackErrors[index] + return result + }) + return trx + }) + } + return await connect(...args) + }) + function causes(error: unknown): unknown[] { + return [ + error, + ...(error instanceof AggregateError ? error.errors.flatMap(causes) : []), + ...(error instanceof Error && error.cause !== undefined ? causes(error.cause) : []) + ] + } + try { + const error = await f.storage.openSnapshotJournalSource(identity, request).catch(value => value) + expect(error).toBeInstanceOf(SnapshotJournalConnectionCleanupError) + expect(causes(error)).toEqual(expect.arrayContaining([sourceError, ...rollbackErrors])) + expect(await f.k('snapshot_journal_receipts')).toEqual([]) + await expect(f.storage.awaitSnapshotJournalCaptureCleanup()).rejects.toBe(error) + } finally { + jest.restoreAllMocks() + await f.storage.destroy().catch(() => undefined) + await rm(f.directory, { recursive: true, force: true }) + } +}) + +test('successful capture followed by an actual reader rollback failure reports exactly that cleanup cause', async () => { + const f = await fixture(), + connect = Connections.withSnapshotJournalConnections, + rollbackError = new Error('successful source reader rollback failed') + jest.spyOn(Connections, 'withSnapshotJournalConnections').mockImplementation(async (...args) => { + const reader = args[1], + transaction = reader.transaction.bind(reader) + Object.defineProperty(reader, 'transaction', { + ...Object.getOwnPropertyDescriptor(reader, 'transaction'), + writable: true + }) + jest.spyOn(reader, 'transaction').mockImplementation(async (...parameters: unknown[]) => { + const trx = await transaction(parameters[0] as Knex.TransactionConfig), + query = trx.client.query.bind(trx.client) + jest.spyOn(trx.client, 'query').mockImplementation(async (...queryParameters: unknown[]) => { + const result: unknown = await query(queryParameters[0], queryParameters[1]) + if (queryParameters[1] === 'ROLLBACK') throw rollbackError + return result + }) + return trx + }) + return await connect(...args) + }) + const lifetime = Capture.retainSnapshotJournalCapture('test', async () => f.k.client.config, identity, request) + try { + const view = await lifetime.opened + expect((await view.readPage('txLabels')).rows.length).toBeGreaterThan(0) + const error = await lifetime.close().catch(value => value) + expect(error).toBeInstanceOf(SnapshotJournalConnectionCleanupError) + expect(error.cause).toMatchObject({ + message: 'Snapshot capture transactions did not drain', + errors: [rollbackError] + }) + await expect(lifetime.closed).rejects.toBe(error) + expect(await f.k('snapshot_journal_receipts')).toHaveLength(1) + } finally { + await lifetime.close().catch(() => undefined) + jest.restoreAllMocks() + await f.close() + } +}) + +test('successful capture followed by an owned-provider close failure reports exactly that cleanup cause', async () => { + const f = await fixture(), + cleanupError = new Error('successful source owned provider did not close'), + destroy = StorageKnex.prototype.destroy + jest.spyOn(StorageKnex.prototype, 'destroy').mockImplementation(async function (this: StorageKnex) { + await destroy.call(this) + if (this !== f.storage) throw cleanupError + }) + const lifetime = Capture.retainSnapshotJournalCapture('test', async () => f.k.client.config, identity, request) + try { + const view = await lifetime.opened + expect((await view.readPage('txLabels')).rows.length).toBeGreaterThan(0) + const error = await lifetime.close().catch(value => value) + expect(error).toBeInstanceOf(SnapshotJournalConnectionCleanupError) + expect(error.cause).toMatchObject({ + message: 'Snapshot capture owned providers did not close', + errors: [cleanupError] + }) + await expect(lifetime.closed).rejects.toBe(error) + expect(await f.k('snapshot_journal_receipts')).toHaveLength(1) + } finally { + await lifetime.close().catch(() => undefined) + jest.restoreAllMocks() + await f.close() + } +}) + +test('uppercase caller identity selects the existing canonical profile and receipt key', async () => { + const f = await fixture(), + upper = '02' + 'AB'.repeat(32) + try { + await f.k('users').where('userId', 1).update({ identityKey: upper.toLowerCase() }) + const view = await f.storage.openSnapshotJournalSource(upper, request) + try { + expect(view.user.identityKey).toBe(upper.toLowerCase()) + expect(view.receiptBinding.identityKey).toBe(upper.toLowerCase()) + } finally { + await view.close() + } + } finally { + await f.close() + } +}) + +test('MySQL capture configures both reserved pools before transactions, commits before verification and drains its pinned reader', async () => { + // This orchestration double checks exact pool/session/transaction boundaries. + // Native MySQL identity, isolation and thirteen-table data are separate fixtures. + const f = await fixture() + const header = await f.k.transaction(t => Archive.readKnexSnapshotArchiveHeader(f.storage, identity, t)) + header.header.sourceStorage.dbtype = 'MySQL' + const sequence: string[] = [], + pools: Knex[] = [], + handles: Array<{ stream: Duplex }> = [] + const factory = jest.requireActual<{ knex: typeof knex }>('knex'), + create = factory.knex + const createSource = Archive.createKnexSnapshotArchiveSource + jest.spyOn(factory, 'knex').mockImplementation((...args: unknown[]) => { + const config = args[0] as Knex.Config + expect(config.pool).toMatchObject({ min: 0, max: 1 }) + expect(config.acquireConnectionTimeout).toBe(5000) + const role = pools.length === 0 ? 'writer' : 'reader', + owner = create(config) + const handle = { + stream: new Duplex({ + read() {}, + write(_chunk, _encoding, callback) { + callback() + } + }) + } + pools.push(owner) + handles.push(handle) + jest.spyOn(owner.client, 'acquireConnection').mockResolvedValue(handle) + jest.spyOn(owner.client, 'releaseConnection').mockResolvedValue(undefined) + const destroy = owner.client.destroy.bind(owner.client) + jest.spyOn(owner.client, 'destroy').mockImplementation(async () => { + handle.stream.destroy() + await destroy() + }) + const raw = owner.client.raw.bind(owner.client) + jest.spyOn(owner.client, 'raw').mockImplementation((...parameters: unknown[]) => { + expect(role).toBe('reader') + expect(parameters).toEqual(['SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY']) + sequence.push('reader session') + const query = raw('SELECT 1') + query.connection = jest.fn().mockImplementation(async connection => { + expect(connection).toBe(handle) + }) + return query + }) + Object.defineProperty(owner, 'transaction', { + ...Object.getOwnPropertyDescriptor(owner, 'transaction'), + writable: true + }) + jest.spyOn(owner, 'transaction').mockImplementation(async (...parameters: unknown[]) => { + expect(parameters).toEqual([{ connection: handle }]) + sequence.push('begin ' + role) + const completion = gate() + let completed = false + return { + client: owner.client, + isTransaction: true, + isCompleted: () => completed, + executionPromise: completion.promise, + commit: async () => { + expect(completed).toBe(false) + sequence.push('commit ' + role) + completed = true + completion.resolve() + }, + rollback: async () => { + expect(completed).toBe(false) + sequence.push('rollback ' + role) + completed = true + completion.resolve() + } + } as unknown as Knex.Transaction + }) + return owner + }) + jest.spyOn(Backend, 'prepareSnapshotJournalCaptureBackend').mockResolvedValue({ kind: 'mysql' }) + jest.spyOn(Backend, 'bindSnapshotJournalCaptureBackend').mockResolvedValue('b'.repeat(64)) + jest.spyOn(Fence, 'reserveSnapshotJournalCaptureFence').mockImplementation(async () => { + sequence.push('fence') + return snapshotJournalRevision('7') + }) + const generation = { + epoch: '00000000-0000-4000-8000-000000000000', + source: 'c'.repeat(64), + plan: 'd'.repeat(64), + nextObject: 1, + ceiling: request.ceiling, + complete: true, + enabled: true + } + jest + .spyOn(MysqlGeneration, 'readSnapshotJournalMysqlGeneration') + .mockImplementation(async (_view, ceiling, policy) => { + expect(ceiling).toBe(request.ceiling) + expect(policy).toEqual(request.receiptPolicy) + sequence.push('generation') + return generation + }) + jest.spyOn(Archive, 'readKnexSnapshotArchiveHeader').mockImplementation(async () => { + sequence.push('header') + return header + }) + jest.spyOn(ArchiveSql, 'snapshotArchiveDatabaseNow').mockResolvedValue(1000) + jest.spyOn(Receipts, 'recordSnapshotJournalReceipt').mockImplementation(async (_barrier, binding, value) => { + expect(binding.identityKey).toBe(identity) + sequence.push('receipt') + return { ...value, floor: snapshotJournalRevision('0'), binding: Receipts.snapshotJournalReceiptBinding(binding) } + }) + jest.spyOn(Closure, 'assertKnexSnapshotArchiveClosure').mockImplementation(async () => { + sequence.push('verify') + }) + jest.spyOn(Archive, 'createKnexSnapshotArchiveSource').mockImplementation((storage, ...rest) => { + expect(storage.getSettings()).toEqual(header.header.sourceStorage) + expect(storage.getSnapshotSync()).toBeUndefined() + return createSource(storage, ...rest) + }) + let lifetime: Capture.SnapshotJournalCaptureLifetime | undefined + try { + lifetime = Capture.retainSnapshotJournalCapture( + 'test', + async () => ({ + client: 'mysql2', + connection: { database: 'synthetic' }, + pool: { min: 2, max: 10 }, + acquireConnectionTimeout: 50000 + }), + identity, + request + ) + const view = await lifetime.opened + expect(sequence).toEqual([ + 'reader session', + 'begin writer', + 'fence', + 'begin reader', + 'generation', + 'header', + 'receipt', + 'commit writer', + 'verify' + ]) + expect(view.receipt).toMatchObject({ highWater: '7', expiresAt: 601000 }) + expect(view.receiptBinding.schema).toBe( + createHash('sha256').update('snapshot-journal-schema-v1\n').update(header.header.sourceSchema).digest('hex') + ) + await lifetime.close() + expect(sequence.at(-1)).toBe('rollback reader') + expect(handles).toHaveLength(2) + expect(handles.every(handle => handle.stream.closed)).toBe(true) + } finally { + await lifetime?.close().catch(() => undefined) + jest.restoreAllMocks() + await Promise.allSettled(pools.map(owner => owner.destroy())) + await f.close() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts index 4c875509b..48803386e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCapture.ts @@ -63,7 +63,7 @@ function ownedConfig(config: Knex.Config): Knex.Config { acquireConnectionTimeout: Math.min(config.acquireConnectionTimeout ?? 5000, 5000) } } -async function endTransactions(transactions: Knex.Transaction[]): Promise { +async function endTransactions(transactions: Knex.Transaction[], failure?: { error: unknown }): Promise { const settled = await Promise.allSettled( transactions.map(async trx => { if (!trx.isCompleted()) { @@ -76,7 +76,7 @@ async function endTransactions(transactions: Knex.Transaction[]): Promise if (failed.length) throw new SnapshotJournalConnectionCleanupError( new AggregateError( - failed.map(result => result.reason), + [...(failure === undefined ? [] : [failure.error]), ...failed.map(result => result.reason)], 'Snapshot capture transactions did not drain' ) ) @@ -116,6 +116,7 @@ async function capture(options: CaptureOptions): Promise { const { storage, writer, write, read, backend, identityKey, request, assertActive, hold } = options const reader = storage.knex, transactions: Knex.Transaction[] = [] + let failure: { error: unknown } | undefined try { await prepareReader(writer, write, reader, read) assertActive() @@ -174,8 +175,11 @@ async function capture(options: CaptureOptions): Promise { await commit(barrier) assertActive() await hold(view, { header, receipt, binding }) + } catch (error) { + failure = { error } + throw error } finally { - await endTransactions(transactions) + await endTransactions(transactions, failure) } } diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.test.ts index 45a7a25cf..590330ec3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureBackend.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { Duplex } from 'node:stream' +import { createHash } from 'node:crypto' import { prepareSnapshotJournalCaptureBackend, bindSnapshotJournalCaptureBackend, @@ -58,7 +59,12 @@ test('fresh independent SQLite pools bind one existing file and physically close }, closeSnapshotJournalCapturePool ) - expect(digest).toMatch(/^[0-9a-f]{64}$/) + expect(digest).toBe( + createHash('sha256') + .update('snapshot-journal-backend-v1\n') + .update(JSON.stringify(['sqlite', expected.kind === 'sqlite' ? expected.file : undefined])) + .digest('hex') + ) expect(connections).toHaveLength(2) expect(connections.map(value => value.open)).toEqual([false, false]) expect(f.writer.client.pool).toBeUndefined() @@ -125,6 +131,9 @@ test.each([ { client: 'pg', connection: { database: 'wallet' } }, { client: 'mysql', connection: { database: 'wallet' } }, { client: 'mysql2', connection: { database: 7 } }, + { client: 'better-sqlite3', connection: { filename: '' } }, + { client: 'better-sqlite3', connection: { filename: 1 } }, + { client: 'mysql2', connection: 'synthetic' }, { client: 'mysql2', connection: null } ])('unsupported backend configuration refuses before constructing an owned pool: %#', async config => { await expect(prepareSnapshotJournalCaptureBackend(config as Knex.Config)).rejects.toThrow('unavailable') @@ -134,7 +143,8 @@ let nativeConnectionId = 0 function mysqlIdentity( serverUuid: unknown, databaseName: unknown, - connectionId: unknown = String(++nativeConnectionId) + connectionId: unknown = String(++nativeConnectionId), + rows?: unknown[] ) { const owner = knex({ client: 'mysql2', @@ -142,7 +152,7 @@ function mysqlIdentity( pool: { min: 0, max: 1 } }) const query = owner.raw('SELECT 1') - query.connection = jest.fn().mockReturnValue(Promise.resolve([[{ serverUuid, databaseName, connectionId }]])) + query.connection = jest.fn().mockReturnValue(Promise.resolve([rows ?? [{ serverUuid, databaseName, connectionId }]])) jest.spyOn(owner.client, 'raw').mockReturnValue(query) return owner } @@ -153,7 +163,20 @@ test('MySQL binds the actual server and database returned by both exact reserved reader = mysqlIdentity(uuid, 'synthetic') try { const expected = await prepareSnapshotJournalCaptureBackend(writer.client.config) - expect(await bindSnapshotJournalCaptureBackend(writer, {}, reader, {}, expected)).toMatch(/^[0-9a-f]{64}$/) + const writerConnection = {}, + readerConnection = {} + expect(await bindSnapshotJournalCaptureBackend(writer, writerConnection, reader, readerConnection, expected)).toBe( + createHash('sha256') + .update('snapshot-journal-backend-v1\n') + .update(JSON.stringify(['mysql', { serverUuid: uuid, database: 'synthetic' }])) + .digest('hex') + ) + const sql = + 'SELECT @@server_uuid AS serverUuid, DATABASE() AS databaseName, CAST(CONNECTION_ID() AS CHAR) AS connectionId' + expect(writer.client.raw).toHaveBeenCalledWith(sql) + expect(reader.client.raw).toHaveBeenCalledWith(sql) + expect(writer.client.raw(sql).connection).toHaveBeenCalledWith(writerConnection) + expect(reader.client.raw(sql).connection).toHaveBeenCalledWith(readerConnection) } finally { await writer.destroy() await reader.destroy() @@ -166,6 +189,9 @@ test.each([ ['different database', uuid, 'other'], ['invalid uuid', 'g'.repeat(36), 'synthetic'], ['missing uuid', undefined, 'synthetic'], + ['uuid prefix', 'x' + uuid, 'synthetic'], + ['uuid suffix', uuid + 'x', 'synthetic'], + ['nonstring database', uuid, 7], ['empty database', uuid, ''], ['oversized database', uuid, 'é'.repeat(129)] ])('MySQL actual identity refuses %s', async (_label, serverUuid, databaseName) => { @@ -261,7 +287,7 @@ test('physical cleanup refusal is typed and retains both source and native relea } }) -test.each(['0', '01', 1, '18446744073709551616'])( +test.each(['0', '01', 1, '18446744073709551616', '12x', 'x12', '1x'])( 'MySQL refuses invalid actual native connection identity %p', async id => { const writer = mysqlIdentity(uuid, 'synthetic', id), @@ -290,6 +316,102 @@ test('distinct pool objects cannot publish two handles addressing the same nativ } }) +test('backend preparation rejects a directory even when its pathname exists', async () => { + const f = await fixture() + try { + await expect( + prepareSnapshotJournalCaptureBackend({ client: 'better-sqlite3', connection: { filename: f.directory } }) + ).rejects.toThrow('unavailable') + } finally { + await f.close() + } +}) + +test.each([ + { rows: [] }, + { + rows: [ + { serverUuid: uuid, databaseName: 'synthetic', connectionId: '1' }, + { serverUuid: uuid, databaseName: 'synthetic', connectionId: '2' } + ] + } +])('MySQL refuses a non-singleton native identity result %#', async ({ rows }) => { + const writer = mysqlIdentity(uuid, 'synthetic', '3', rows), + reader = mysqlIdentity(uuid, 'synthetic', '4') + try { + await expect(bindSnapshotJournalCaptureBackend(writer, {}, reader, {}, { kind: 'mysql' })).rejects.toThrow( + 'changed' + ) + } finally { + jest.restoreAllMocks() + await Promise.all([writer.destroy(), reader.destroy()]) + } +}) + +test('MySQL accepts exact 256 UTF8 database bytes and the unsigned64 connection boundary', async () => { + const database = 'é'.repeat(128) + const writer = mysqlIdentity(uuid, database, '18446744073709551615'), + reader = mysqlIdentity(uuid, database, '1') + try { + expect(await bindSnapshotJournalCaptureBackend(writer, {}, reader, {}, { kind: 'mysql' })).toBe( + createHash('sha256') + .update('snapshot-journal-backend-v1\n') + .update(JSON.stringify(['mysql', { serverUuid: uuid, database }])) + .digest('hex') + ) + } finally { + jest.restoreAllMocks() + await Promise.all([writer.destroy(), reader.destroy()]) + } +}) + +test('native pool destruction failures retain every cause and remove the close listener', async () => { + const owner = mysqlIdentity(uuid, 'synthetic') + const stream = new Duplex({ + read() {}, + write(_chunk, _encoding, callback) { + callback() + } + }) + const destroyFailure = new Error('destroy failed'), + releaseFailure = new Error('release failed') + jest.spyOn(owner.client, 'destroy').mockRejectedValue(destroyFailure) + jest.spyOn(owner.client, 'releaseConnection').mockRejectedValue(releaseFailure) + try { + await expect(closeSnapshotJournalCapturePool(owner, { stream })).rejects.toMatchObject({ + message: 'Snapshot capture pool did not close', + errors: [destroyFailure, releaseFailure] + }) + expect(stream.listenerCount('close')).toBe(0) + } finally { + stream.destroy() + jest.restoreAllMocks() + await owner.destroy() + } +}) + +test.each(['better-sqlite3', 'mysql2'])( + 'successful pool calls cannot claim an unclosed %s native handle', + async client => { + const owner = knex({ + client, + connection: client === 'better-sqlite3' ? { filename: ':memory:' } : { database: 'synthetic' }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + jest.spyOn(owner.client, 'destroy').mockResolvedValue(undefined) + jest.spyOn(owner.client, 'releaseConnection').mockResolvedValue(undefined) + try { + await expect(closeSnapshotJournalCapturePool(owner, { open: true })).rejects.toThrow( + 'Snapshot capture native connection did not close' + ) + } finally { + jest.restoreAllMocks() + await owner.destroy() + } + } +) + test('fresh native connection IDs do not change the durable backend binding', async () => { const pools = [ mysqlIdentity(uuid, 'synthetic', '1'), diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.test.ts index fec82bf39..7ef2c54e4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalCaptureFence.test.ts @@ -141,3 +141,187 @@ test('capture refuses ambient nontransaction, unsupported driver and busy-waitin await f.close() } }) + +test.each(['missing-clock', 'invalid-enabled', 'zero-ceiling', 'revision-over-ceiling', 'disabled'] as const)( + 'SQLite capture reads the exact persisted %s state before allocation', + async state => { + const f = await fixture() + try { + if (state === 'missing-clock') await f.writer('snapshot_journal_clock').delete() + else { + await f.writer.raw('PRAGMA ignore_check_constraints=ON') + if (state === 'invalid-enabled') await f.writer('snapshot_journal_clock').update({ enabled: 2 }) + else if (state === 'zero-ceiling') await f.writer('snapshot_journal_clock').update({ ceiling: 0 }) + else if (state === 'revision-over-ceiling') + await f.writer('snapshot_journal_clock').update({ revision: 2, ceiling: 1 }) + else await f.writer('snapshot_journal_clock').update({ enabled: 0, reason: 'capacity-exhausted' }) + await f.writer.raw('PRAGMA ignore_check_constraints=OFF') + await f.barrier.raw('PRAGMA ignore_check_constraints=ON') + } + if (state === 'disabled') expect(await f.barrier.transaction(reserveSnapshotJournalCaptureFence)).toBeUndefined() + else + await expect(f.barrier.transaction(reserveSnapshotJournalCaptureFence)).rejects.toThrow( + 'Invalid snapshot journal capture barrier or clock' + ) + } finally { + await f.close() + } + } +) + +test.each(['no-clock', 'invalid-enabled', 'wrong-increment', 'over-ceiling'] as const)( + 'SQLite capture refuses %s returned after the actual clock advance', + async fault => { + const f = await fixture('10') + try { + await f.barrier.raw('PRAGMA ignore_check_constraints=ON') + let effect: string + if (fault === 'no-clock') effect = 'DELETE FROM snapshot_journal_clock' + else if (fault === 'invalid-enabled') effect = 'UPDATE snapshot_journal_clock SET enabled=2' + else effect = 'UPDATE snapshot_journal_clock SET revision=' + (fault === 'wrong-increment' ? '0' : '11') + await f.writer.raw( + 'CREATE TRIGGER corrupt_capture_clock AFTER UPDATE ON snapshot_journal_clock WHEN NEW.revision=1 BEGIN ' + + effect + + '; END' + ) + await expect(f.barrier.transaction(reserveSnapshotJournalCaptureFence)).rejects.toThrow( + 'Invalid snapshot journal capture barrier or clock' + ) + expect((await f.writer('snapshot_journal_clock').first()).revision).toBe(0) + } finally { + await f.close() + } + } +) + +test('SQLite capture supports the sqlite3 driver identity', async () => { + const f = await fixture() + try { + await f.barrier.transaction(async t => { + t.client.config.client = 'sqlite3' + expect(await reserveSnapshotJournalCaptureFence(t)).toBe('1') + }) + } finally { + await f.close() + } +}) + +test('SQLite capture refuses native non-WAL admission before clock access', async () => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + try { + await k.raw('PRAGMA busy_timeout=0') + await expect(k.transaction(reserveSnapshotJournalCaptureFence)).rejects.toThrow( + 'Invalid snapshot journal capture barrier or clock' + ) + } finally { + await k.destroy() + } +}) + +/** Real Knex MySQL compilation and response decoding, with only the native + * transport replaced. Actual isolation/locking is covered by the native cohort. */ +function mysqlTransport(ceiling: string, allocated: unknown, disabled = false, missing = false) { + const owner = knex({ client: 'mysql2', connection: { database: 'synthetic' }, pool: { min: 0, max: 1 } }) + const queries: Array<{ sql: string; bindings: unknown[] }> = [] + const invalidations: unknown[][] = [] + jest.spyOn(owner.client, 'acquireConnection').mockResolvedValue({}) + jest.spyOn(owner.client, 'releaseConnection').mockResolvedValue(undefined) + jest.spyOn(owner.client, 'query').mockImplementation(async (...args: unknown[]) => { + const query = args[1] as { sql: string; method: string; bindings: unknown[] } + queries.push({ sql: query.sql, bindings: query.bindings ?? [] }) + let rows: unknown + if ( + query.sql === + 'select CAST(`ceiling` AS CHAR) as `ceiling` from `snapshot_journal_clock` where `id` = ? limit ? for update nowait' + ) + rows = missing ? [] : [{ ceiling }] + else if (query.sql === 'select `id` from `snapshot_journal_invalid` where `id` = ? limit ? for update nowait') + rows = disabled ? [{ id: 1 }] : [] + else if (query.sql === 'insert into `snapshot_journal_events` () values ()') rows = { insertId: allocated } + else if (query.sql === 'SELECT CAST(LAST_INSERT_ID() AS CHAR) revision') rows = [{ revision: allocated }] + else if (query.sql === 'delete from `snapshot_journal_events` where `revision` = ?') rows = { affectedRows: 1 } + else if (query.sql === 'insert ignore into `snapshot_journal_invalid` (`id`, `reason`) values (?, ?)') { + invalidations.push(query.bindings) + rows = { insertId: 1 } + } else throw new Error('Unexpected capture SQL: ' + query.sql) + return { ...query, response: [rows, []] } + }) + return { + k: Object.assign(owner, { isTransaction: true }), + queries, + invalidations, + close: async () => { + jest.restoreAllMocks() + await owner.destroy() + } + } +} + +test.each(['mysql2', 'mysql'])('capture uses exact current NOWAIT reads and decimal allocation on %s', async client => { + const f = mysqlTransport(MAX_SNAPSHOT_JOURNAL_REVISION, '9007199254740993') + try { + f.k.client.config.client = client + expect(await reserveSnapshotJournalCaptureFence(f.k)).toBe('9007199254740993') + expect(f.queries.map(query => query.bindings)).toEqual([[1, 1], [1, 1], [], [], ['9007199254740993']]) + expect(f.invalidations).toEqual([]) + } finally { + await f.close() + } +}) + +test.each([ + ['2', '2', undefined], + ['2', '3', 'capacity-exhausted'], + [MAX_SNAPSHOT_JOURNAL_REVISION, '9223372036854775808', 'revision-exhausted'] +] as const)('MySQL allocation %s/%s retains exact exhaustion identity', async (ceiling, allocated, reason) => { + const f = mysqlTransport(ceiling, allocated) + try { + expect(await reserveSnapshotJournalCaptureFence(f.k)).toBe(reason ? undefined : allocated) + expect(f.invalidations).toEqual(reason ? [[1, reason]] : []) + expect(f.queries[4]).toEqual({ + sql: 'delete from `snapshot_journal_events` where `revision` = ?', + bindings: [allocated] + }) + } finally { + await f.close() + } +}) + +test.each(['0', '01', '', 'x1', '1x', 1, undefined])( + 'MySQL refuses malformed native allocation %p before deleting an event', + async allocated => { + const f = mysqlTransport('10', allocated) + try { + await expect(reserveSnapshotJournalCaptureFence(f.k)).rejects.toThrow( + 'Invalid snapshot journal capture barrier or clock' + ) + expect(f.queries).toHaveLength(4) + expect(f.invalidations).toEqual([]) + } finally { + await f.close() + } + } +) + +test.each(['disabled', 'missing', 'zero-ceiling'] as const)( + 'MySQL %s state stops before event allocation', + async state => { + const f = mysqlTransport(state === 'zero-ceiling' ? '0' : '10', '1', state === 'disabled', state === 'missing') + try { + if (state === 'disabled') expect(await reserveSnapshotJournalCaptureFence(f.k)).toBeUndefined() + else + await expect(reserveSnapshotJournalCaptureFence(f.k)).rejects.toThrow( + 'Invalid snapshot journal capture barrier or clock' + ) + expect(f.queries).toHaveLength(state === 'disabled' ? 2 : 1) + expect(f.invalidations).toEqual([]) + } finally { + await f.close() + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts index 37d3d498a..395a8503d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts @@ -1,4 +1,4 @@ -import { knex } from 'knex' +import { knex, type Knex } from 'knex' import { withSnapshotJournalConnections, SnapshotJournalConnectionCleanupError } from './SnapshotJournalConnections' function deferred() { @@ -115,7 +115,8 @@ test.each(['writer', 'reader'] as const)( await allow.promise return await acquire() }) - jest.spyOn(f[failed].client, 'acquireConnection').mockRejectedValue(new Error('acquire failed')) + const failure = new Error('acquire failed') + jest.spyOn(f[failed].client, 'acquireConnection').mockRejectedValue(failure) let settled = false const action = jest.fn(async () => 7) const operation = withSnapshotJournalConnections(f.writer, f.reader, () => undefined, action) @@ -133,6 +134,7 @@ test.each(['writer', 'reader'] as const)( expect(action).not.toHaveBeenCalled() allow.resolve() await rejection + await expect(operation).rejects.toMatchObject({ errors: [failure] }) expect(late.client.pool.numUsed()).toBe(0) expect(action).not.toHaveBeenCalled() } finally { @@ -200,7 +202,10 @@ test('cleanup failure waits for the other release and preserves both failure cau release.resolve() await rejection await expect(operation).rejects.toMatchObject({ + name: 'SnapshotJournalConnectionCleanupError', + message: 'Snapshot journal connection cleanup failed', cause: { + message: 'Snapshot connection ownership did not drain', errors: [ expect.objectContaining({ message: 'read failed' }), expect.objectContaining({ message: 'writer release failed' }) @@ -215,6 +220,73 @@ test('cleanup failure waits for the other release and preserves both failure cau } }) +test('distinct wrappers around the same client refuse before acquisition', async () => { + const f = await fixture() + try { + const alias = { ...f.reader, client: f.writer.client } as Knex + const acquire = jest.spyOn(f.writer.client, 'acquireConnection') + const action = jest.fn(async () => 1) + await expect(withSnapshotJournalConnections(f.writer, alias, () => undefined, action)).rejects.toThrow( + 'Snapshot capture requires two independent owned connection pools' + ) + expect(acquire).not.toHaveBeenCalled() + expect(action).not.toHaveBeenCalled() + } finally { + jest.restoreAllMocks() + await f.close() + } +}) + +test('two pools returning the same native handle release both reservations without running the barrier', async () => { + const f = await fixture() + const connection = await f.writer.client.acquireConnection() + const release = f.writer.client.releaseConnection.bind(f.writer.client) + try { + jest.spyOn(f.writer.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(f.reader.client, 'acquireConnection').mockResolvedValue(connection) + const drain = jest.fn(async () => undefined) + const action = jest.fn(async () => 1) + await expect(withSnapshotJournalConnections(f.writer, f.reader, () => undefined, action, drain)).rejects.toThrow( + 'Snapshot pools returned the same native connection' + ) + expect(action).not.toHaveBeenCalled() + expect(drain.mock.calls).toEqual([ + [f.writer, connection], + [f.reader, connection] + ]) + } finally { + jest.restoreAllMocks() + await release(connection) + await f.close() + } +}) + +test('a successful callback with failed release reports only the release failure', async () => { + const f = await fixture() + const failure = new Error('owned writer release failed') + try { + const operation = withSnapshotJournalConnections( + f.writer, + f.reader, + () => undefined, + async () => 42, + async (owner, connection) => { + await owner.client.releaseConnection(connection) + if (owner === f.writer) throw failure + } + ) + await expect(operation).rejects.toMatchObject({ + name: 'SnapshotJournalConnectionCleanupError', + message: 'Snapshot journal connection cleanup failed', + cause: { message: 'Snapshot connection ownership did not drain', errors: [failure] } + }) + expect(f.writer.client.pool.numUsed()).toBe(0) + expect(f.reader.client.pool.numUsed()).toBe(0) + } finally { + await f.close() + } +}) + test('already cancelled calls and shared pools refuse before native acquisition', async () => { const f = await fixture() try { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts index 979c8a786..e05c6403d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalMaintenance.test.ts @@ -6,6 +6,7 @@ import { StorageKnex } from '../../StorageKnex' import { StorageProvider } from '../../StorageProvider' import { seedArchiveClosure } from '../../../../test/utils/snapshotArchiveFixtures' import { maintainSnapshotJournal, type SnapshotJournalMaintenanceRequest } from './SnapshotJournalMaintenance' +import * as Maintenance from './SnapshotJournalMaintenance' import { snapshotJournalRevision, type SnapshotJournalRevision } from './SnapshotJournalRevision' import * as Sqlite from './SnapshotJournalSqliteGeneration' import * as Mysql from './SnapshotJournalMysqlGeneration' @@ -95,6 +96,100 @@ test('the provider uses owned WAL transactions for floor and bounded collection' await f.close() } }) +test('invalid maintenance input rejects with its cause and releases admission for a valid request', async () => { + const f = await fixture() + try { + await expect(f.storage.awaitSnapshotJournalMaintenanceCleanup()).resolves.toBeUndefined() + await expect(f.storage.maintainSnapshotJournal({ ...f.request, epoch: 'foreign' })).rejects.toThrow( + 'Snapshot journal maintenance generation is unavailable or changed' + ) + expect(await f.storage.maintainSnapshotJournal(f.request)).toMatchObject({ kind: 'floor', value: { floor: '0' } }) + await f.storage.awaitSnapshotJournalMaintenanceCleanup() + } finally { + await f.close() + } +}) +test('destruction fences pending maintenance configuration before database access', async () => { + const f = await fixture(), + entered = gate(), + release = gate(), + maintain = Maintenance.maintainSnapshotJournal, + acquire = jest.spyOn(f.k.client, 'acquireConnection') + let refused: unknown + jest.spyOn(Maintenance, 'maintainSnapshotJournal').mockImplementation((configure, ...rest) => + maintain( + async () => { + entered.resolve() + await release.promise + try { + return await configure() + } catch (error) { + refused = error + throw error + } + }, + ...rest + ) + ) + try { + const running = f.storage.maintainSnapshotJournal(f.request), + outcome = running.then( + value => ({ value }), + error => ({ error }) + ) + await entered.promise + const destruction = f.storage.destroy().then( + value => ({ value }), + error => ({ error }) + ) + release.resolve() + const destroyed = await destruction + expect(refused).toMatchObject({ message: 'Snapshot journal maintenance is unavailable after destruction begins' }) + expect(destroyed).toHaveProperty('error', refused) + expect(await outcome).toHaveProperty('error') + expect(acquire).not.toHaveBeenCalled() + } finally { + release.resolve() + jest.restoreAllMocks() + await f.close() + } +}) +test('provider destruction stops and drains active maintenance before closing the foreground pool', async () => { + const f = await fixture(), + entered = gate(), + release = gate(), + advance = Receipts.advanceSnapshotJournalFloor + jest.spyOn(Receipts, 'advanceSnapshotJournalFloor').mockImplementation(async (...args) => { + const value = await advance(...args) + entered.resolve() + await release.promise + return value + }) + try { + const running = f.storage.maintainSnapshotJournal(f.request), + outcome = running.then( + value => ({ value }), + error => ({ error }) + ) + await entered.promise + let drained = false + const destruction = f.storage.destroy().then(() => { + drained = true + }) + await new Promise(resolve => setImmediate(resolve)) + expect(drained).toBe(false) + release.resolve() + await destruction + expect(await outcome).toMatchObject({ + error: expect.objectContaining({ message: 'Snapshot journal maintenance is closed' }) + }) + await f.storage.awaitSnapshotJournalMaintenanceCleanup() + expect(drained).toBe(true) + } finally { + release.resolve() + await f.close() + } +}) test('request binding is validated before native configuration or allocation', () => { const resolve = jest.fn, []>() for (const value of [ diff --git a/patches/metro-file-map@0.87.1.patch b/patches/metro-file-map@0.87.1.patch new file mode 100644 index 000000000..3223d791c --- /dev/null +++ b/patches/metro-file-map@0.87.1.patch @@ -0,0 +1,84 @@ +diff --git a/src/watchers/common.js b/src/watchers/common.js +--- a/src/watchers/common.js ++++ b/src/watchers/common.js +@@ -11,7 +11,7 @@ + exports.includedByGlob = includedByGlob; + exports.posixPathMatchesPattern = void 0; + exports.typeFromStat = typeFromStat; +-var _micromatch = _interopRequireDefault(require("micromatch")); ++var _picomatch = _interopRequireDefault(require("picomatch")); + var _nodePath = _interopRequireDefault(require("node:path")); + function _interopRequireDefault(e) { + return e && e.__esModule ? e : { default: e }; +@@ -20,11 +20,21 @@ + const TOUCH_EVENT = (exports.TOUCH_EVENT = "touch"); + const RECRAWL_EVENT = (exports.RECRAWL_EVENT = "recrawl"); + const ALL_EVENT = (exports.ALL_EVENT = "all"); ++// Keep Metro's matching semantics using micromatch.some's existing matcher. ++// The unused brace expansion package is excluded from this build-tool closure. ++function matchesAny(relativePath, patterns, options) { ++ for (const pattern of typeof patterns === "string" ? [patterns] : patterns) { ++ if (_picomatch.default(String(pattern), options)(relativePath)) { ++ return true; ++ } ++ } ++ return false; ++} + function includedByGlob(type, globs, dot, relativePath) { + if (globs.length === 0 || type !== "f") { +- return dot || _micromatch.default.some(relativePath, "**/*"); ++ return dot || matchesAny(relativePath, "**/*"); + } +- return _micromatch.default.some(relativePath, globs, { ++ return matchesAny(relativePath, globs, { + dot, + }); + } +diff --git a/src/watchers/common.js.flow b/src/watchers/common.js.flow +--- a/src/watchers/common.js.flow ++++ b/src/watchers/common.js.flow +@@ -17,8 +17,8 @@ + import type {ChangeEventMetadata} from '../flow-types'; + import type {Stats} from 'node:fs'; + +-// $FlowFixMe[untyped-import] - Write libdefs for `micromatch` +-import micromatch from 'micromatch'; ++// $FlowFixMe[untyped-import] - Write libdefs for `picomatch` ++import picomatch from 'picomatch'; + import path from 'node:path'; + + /** +@@ -38,6 +38,21 @@ + watchmanPath?: string, + }>; + ++// Keep Metro's matching semantics using micromatch.some's existing matcher. ++// The unused brace expansion package is excluded from this build-tool closure. ++function matchesAny( ++ relativePath: string, ++ patterns: string | ReadonlyArray, ++ options?: Readonly<{dot?: boolean}>, ++): boolean { ++ for (const pattern of typeof patterns === 'string' ? [patterns] : patterns) { ++ if (picomatch(String(pattern), options)(relativePath)) { ++ return true; ++ } ++ } ++ return false; ++} ++ + /** + * Checks a file relative path against the globs array. + */ +@@ -50,9 +65,9 @@ + // For non-regular files or if there are no glob matchers, just respect the + // `dot` option to filter dotfiles if dot === false. + if (globs.length === 0 || type !== 'f') { +- return dot || micromatch.some(relativePath, '**/*'); ++ return dot || matchesAny(relativePath, '**/*'); + } +- return micromatch.some(relativePath, globs, {dot}); ++ return matchesAny(relativePath, globs, {dot}); + } + + /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dd6b8690..01eebae25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,14 @@ overrides: qs@<6.16.0: 6.16.0 js-yaml@<3.15.2: 3.15.2 lodash-es@<4.18.0: 4.18.1 + metro-file-map@0.87.1>micromatch: '-' + +packageExtensionsChecksum: sha256-j1ipTIriuh1r8NrHzy5rLoGNr081FjMxVoEY/IdY3mE= + +patchedDependencies: + metro-file-map@0.87.1: + hash: 28741c2833798bbfda1432bd62f13f338d31f12a6161f4268a6cc6f01022018b + path: patches/metro-file-map@0.87.1.patch importers: @@ -5085,10 +5093,6 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - browserslist@4.29.0: resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -5955,10 +5959,6 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - finalhandler@1.1.2: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} @@ -6335,10 +6335,6 @@ packages: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -7158,10 +7154,6 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -8400,10 +8392,6 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -12562,10 +12550,6 @@ snapshots: dependencies: balanced-match: 4.0.4 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - browserslist@4.29.0: dependencies: baseline-browser-mapping: 2.11.24 @@ -13453,10 +13437,6 @@ snapshots: file-uri-to-path@1.0.0: {} - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - finalhandler@1.1.2: dependencies: debug: 2.6.9 @@ -13861,8 +13841,6 @@ snapshots: is-network-error@1.3.2: {} - is-number@7.0.0: {} - is-plain-obj@4.1.0: {} is-plain-object@2.0.4: @@ -14924,7 +14902,7 @@ snapshots: lodash.throttle: 4.1.1 metro-resolver: 0.87.1 - metro-file-map@0.87.1: + metro-file-map@0.87.1(patch_hash=28741c2833798bbfda1432bd62f13f338d31f12a6161f4268a6cc6f01022018b): dependencies: debug: 4.4.3 fb-watchman: 2.0.2 @@ -14932,8 +14910,8 @@ snapshots: graceful-fs: 4.2.11 invariant: 2.2.4 jest-worker: 29.7.0 - micromatch: 4.0.8 nullthrows: 1.1.1 + picomatch: 2.3.2 transitivePeerDependencies: - supports-color @@ -15032,7 +15010,7 @@ snapshots: metro-cache-key: 0.87.1 metro-config: 0.87.1 metro-core: 0.87.1 - metro-file-map: 0.87.1 + metro-file-map: 0.87.1(patch_hash=28741c2833798bbfda1432bd62f13f338d31f12a6161f4268a6cc6f01022018b) metro-resolver: 0.87.1 metro-runtime: 0.87.1 metro-source-map: 0.87.1 @@ -15322,11 +15300,6 @@ snapshots: transitivePeerDependencies: - supports-color - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -16686,10 +16659,6 @@ snapshots: dependencies: tldts-core: 6.1.86 - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - toidentifier@1.0.1: {} toml@4.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 526ae7126..edf5653d5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,9 +61,21 @@ overrides: # by GHSA-r5fr-rjxr-66jc (_.template imports key-name code injection). Select # the patched release already used elsewhere in the graph; the API is unchanged. lodash-es@<4.18.0: 4.18.1 + # Metro-file-map only uses micromatch.some, which delegates to Picomatch. + # The paired patch retains that matcher and removes the affected unused closure. + 'metro-file-map@0.87.1>micromatch': '-' strictDepBuilds: true trustPolicy: no-downgrade # Apply provenance downgrade checks to the recent supply-chain window. Older # packages predate consistent registry attestations and otherwise create known # false positives (for example chokidar 4.0.3 in pnpm's own documentation). trustPolicyIgnoreAfter: 525600 + +# Temporary exact-version build-tool repair; ownership/removal criteria live in +# governance/dependency-release-policy.json and the override exception registry. +packageExtensions: + 'metro-file-map@0.87.1': + dependencies: + picomatch: 2.3.2 +patchedDependencies: + 'metro-file-map@0.87.1': patches/metro-file-map@0.87.1.patch diff --git a/scripts/check-wallet-toolbox-platform.mjs b/scripts/check-wallet-toolbox-platform.mjs index 51e7b5758..bff2ab56b 100644 --- a/scripts/check-wallet-toolbox-platform.mjs +++ b/scripts/check-wallet-toolbox-platform.mjs @@ -9,6 +9,7 @@ import process from 'node:process' import { fileURLToPath, pathToFileURL } from 'node:url' import { createCommandRunner } from './lib/command-runner.mjs' +import { assertMetroWatcherContract } from './lib/metro-watcher-contract.mjs' const COMMAND_TIMEOUT_MS = 240_000 const MAX_BUFFER_BYTES = 30 * 1024 * 1024 @@ -410,6 +411,10 @@ function hermesCompilerPath() { } async function checkMobile(consumerDirectory, budget) { + console.log( + 'Verified Metro watcher contract:', + assertMetroWatcherContract(toolResolver('metro/package.json')) + ) const entryPath = path.join(consumerDirectory, 'index.js') await fs.writeFile(entryPath, consumerEntry(expectedPackage)) await fs.writeFile( diff --git a/scripts/dependency-release-governance.test.mjs b/scripts/dependency-release-governance.test.mjs index 38725410b..7aeb251b7 100644 --- a/scripts/dependency-release-governance.test.mjs +++ b/scripts/dependency-release-governance.test.mjs @@ -22,7 +22,7 @@ test('dependency and release governance is internally complete', () => { assert.deepEqual(validateDependencyReleaseGovernance(), []) const overrides = collectOverrides() - assert.equal(overrides.length, 26) + assert.equal(overrides.length, 27) assert.equal(overrides.filter(entry => entry.selector === 'gaxios').length, 8) assert.equal(overrides.filter(entry => entry.selector === 'uuid').length, 3) assert.equal(overrides.filter(entry => entry.selector === 'brace-expansion').length, 4) @@ -36,6 +36,10 @@ test('dependency and release governance is internally complete', () => { assert.equal(overrides.filter(entry => entry.selector === 'js-yaml').length, 1) assert.equal(overrides.find(entry => entry.selector === 'lodash-es@<4.18.0')?.value, '4.18.1') assert.equal(overrides.filter(entry => entry.selector.includes('image-size')).length, 0) + assert.equal( + overrides.find(entry => entry.selector === 'metro-file-map@0.87.1>micromatch')?.value, + "'-'" + ) }) test('pnpm override parsing preserves scoped parent selectors', () => { diff --git a/scripts/lib/fixtures/metro-watcher-contract.json b/scripts/lib/fixtures/metro-watcher-contract.json new file mode 100644 index 000000000..661be712c --- /dev/null +++ b/scripts/lib/fixtures/metro-watcher-contract.json @@ -0,0 +1,1702 @@ +{ + "oracle": "Unmodified published metro-file-map0.87.1 with micromatch4.0.8/Picomatch2.3.2", + "oracleSourceSha256": "d962d60bf25b7bd12a7679894fbb0c7e9d646b4efb1ab4a97db6f13c62eb8722", + "files": [ + "mod.ts", + "src/mod.ts", + "src/mod.tsx", + "src/sub/file.js", + ".hidden/mod.ts", + "src/.hidden/mod.ts", + "src/generated/mod.ts", + "src/test.generated.ts", + "src/B.ts", + "src/a.ts", + "src/sub/file", + "src\\mod.ts", + "", + "/src/mod.ts" + ], + "cases": [ + { + "type": "f", + "dot": false, + "globs": [], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "f", + "dot": false, + "globs": ["**/*"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "f", + "dot": false, + "globs": ["**/*.ts"], + "expected": [ + true, + true, + false, + false, + false, + false, + true, + true, + true, + true, + false, + true, + false, + true + ] + }, + { + "type": "f", + "dot": false, + "globs": ["**/*.{ts,tsx}"], + "expected": [ + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + false, + true, + false, + true + ] + }, + { + "type": "f", + "dot": false, + "globs": ["!**/generated/**"], + "expected": [ + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "f", + "dot": false, + "globs": ["src/**", "!**/*.generated.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "f", + "dot": false, + "globs": ["**/[a-z]*.ts"], + "expected": [ + true, + true, + false, + false, + false, + false, + true, + true, + false, + true, + false, + true, + false, + true + ] + }, + { + "type": "f", + "dot": false, + "globs": ["**/+(index|mod).ts"], + "expected": [ + true, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "type": "f", + "dot": false, + "globs": ["**/.hidden/**"], + "expected": [ + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + { + "type": "f", + "dot": false, + "globs": ["*.js", "**/mod.ts"], + "expected": [ + true, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": [], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": ["**/*"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": ["**/*.ts"], + "expected": [ + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true, + false, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": ["**/*.{ts,tsx}"], + "expected": [ + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + false, + true, + false, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": ["!**/generated/**"], + "expected": [ + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": ["src/**", "!**/*.generated.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": ["**/[a-z]*.ts"], + "expected": [ + true, + true, + false, + false, + true, + true, + true, + true, + false, + true, + false, + true, + false, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": ["**/+(index|mod).ts"], + "expected": [ + true, + true, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "type": "f", + "dot": true, + "globs": ["**/.hidden/**"], + "expected": [ + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + { + "type": "f", + "dot": true, + "globs": ["*.js", "**/mod.ts"], + "expected": [ + true, + true, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": [], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["**/*"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["**/*.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["**/*.{ts,tsx}"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["!**/generated/**"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["src/**", "!**/*.generated.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["**/[a-z]*.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["**/+(index|mod).ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["**/.hidden/**"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": false, + "globs": ["*.js", "**/mod.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": [], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["**/*"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["**/*.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["**/*.{ts,tsx}"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["!**/generated/**"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["src/**", "!**/*.generated.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["**/[a-z]*.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["**/+(index|mod).ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["**/.hidden/**"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "d", + "dot": true, + "globs": ["*.js", "**/mod.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": [], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["**/*"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["**/*.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["**/*.{ts,tsx}"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["!**/generated/**"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["src/**", "!**/*.generated.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["**/[a-z]*.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["**/+(index|mod).ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["**/.hidden/**"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": false, + "globs": ["*.js", "**/mod.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": [], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["**/*"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["**/*.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["**/*.{ts,tsx}"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["!**/generated/**"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["src/**", "!**/*.generated.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["**/[a-z]*.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["**/+(index|mod).ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["**/.hidden/**"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": "l", + "dot": true, + "globs": ["*.js", "**/mod.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": false, + "globs": [], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["**/*"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["**/*.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["**/*.{ts,tsx}"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["!**/generated/**"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["src/**", "!**/*.generated.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["**/[a-z]*.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["**/+(index|mod).ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["**/.hidden/**"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": false, + "globs": ["*.js", "**/mod.ts"], + "expected": [ + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + { + "type": null, + "dot": true, + "globs": [], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["**/*"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["**/*.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["**/*.{ts,tsx}"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["!**/generated/**"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["src/**", "!**/*.generated.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["**/[a-z]*.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["**/+(index|mod).ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["**/.hidden/**"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "type": null, + "dot": true, + "globs": ["*.js", "**/mod.ts"], + "expected": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} diff --git a/scripts/lib/metro-watcher-contract.mjs b/scripts/lib/metro-watcher-contract.mjs new file mode 100644 index 000000000..de8e3635d --- /dev/null +++ b/scripts/lib/metro-watcher-contract.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' + +/** Check the real installed watcher against an immutable published oracle. + * The ordinary inputs include negative arrays, brace alternatives, extglobs, + * dot files, separators and every watcher file type. No advisory payloads. */ +export function assertMetroWatcherContract(metroPackagePath) { + const metroRequire = createRequire(metroPackagePath) + const commonPath = path.join( + path.dirname(metroRequire.resolve('metro-file-map/package.json')), + 'src/watchers/common.js' + ) + const watcherRequire = createRequire(commonPath) + const common = watcherRequire(commonPath) + const fixture = JSON.parse( + fs.readFileSync(new URL('./fixtures/metro-watcher-contract.json', import.meta.url), 'utf8') + ) + let checked = 0 + for (const { type, dot, globs, expected } of fixture.cases) { + for (const [index, file] of fixture.files.entries()) { + assert.equal( + common.includedByGlob(type, globs, dot, file), + expected[index], + `Metro watcher compatibility: ${JSON.stringify({ type, dot, globs, file })}` + ) + checked++ + } + } + assert.equal(checked, 1120) + for (const [name, value] of Object.entries({ + DELETE_EVENT: 'delete', + TOUCH_EVENT: 'touch', + RECRAWL_EVENT: 'recrawl', + ALL_EVENT: 'all' + })) + assert.equal(common[name], value) + for (const [kind, expected] of [ + ['isSymbolicLink', 'l'], + ['isDirectory', 'd'], + ['isFile', 'f'] + ]) { + const stat = Object.fromEntries( + ['isSymbolicLink', 'isDirectory', 'isFile'].map(method => [method, () => method === kind]) + ) + assert.equal(common.typeFromStat(stat), expected) + } + for (const file of ['a/b.ts', 'a\\b.ts']) { + assert.equal(common.posixPathMatchesPattern(/\.ts$/, file), true) + assert.equal(common.posixPathMatchesPattern(/\.js$/, file), false) + } + assert.equal(common.posixPathMatchesPattern(/^a\/b\.ts$/, path.join('a', 'b.ts')), true) + assert.equal(watcherRequire('picomatch/package.json').version, '2.3.2') + for (const name of ['micromatch', 'braces']) { + assert.throws(() => watcherRequire.resolve(name), { code: 'MODULE_NOT_FOUND' }) + } + return { publishedOracle: fixture.oracle, watcherCases: checked, removedDependencyClosure: true } +} From b534ab9f9b3efab181538271ddc7be062d632ed1 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 20:42:56 -0700 Subject: [PATCH 099/127] fix(ci): update MessageBox multipart dependency and watcher style --- infra/message-box-server/README.md | 9 +++++++++ infra/message-box-server/package-lock.json | 6 +++--- scripts/lib/metro-watcher-contract.mjs | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/infra/message-box-server/README.md b/infra/message-box-server/README.md index bcdf6ad42..f67ecfcb7 100644 --- a/infra/message-box-server/README.md +++ b/infra/message-box-server/README.md @@ -9,6 +9,15 @@ The maintained source lives in [`bsv-blockchain/ts-stack`](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/message-box-server). The service is distributed as a container; it is not a public npm package. +The standalone lock selects `@fastify/busboy` 3.2.2 within its existing +transitive dependency range, addressing +[GHSA-xjh9-v7x6-24jw](https://github.com/advisories/GHSA-xjh9-v7x6-24jw) and +[GHSA-x8mw-p69m-v3mx](https://github.com/advisories/GHSA-x8mw-p69m-v3mx). +No service API, request ceiling, runtime requirement or database migration +changes. Use the frozen lock and the protected container release process; +existing deployed images receive the fix only through a separately authorized +image promotion. + See [Service Resource Profiles](../../docs/reference/service-resource-profiles.md) for all runtime ceilings, BRC-105 pricing, capacity evidence, and HPA prerequisites. diff --git a/infra/message-box-server/package-lock.json b/infra/message-box-server/package-lock.json index b583c9c6f..a4169da27 100644 --- a/infra/message-box-server/package-lock.json +++ b/infra/message-box-server/package-lock.json @@ -1286,9 +1286,9 @@ } }, "node_modules/@fastify/busboy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", - "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz", + "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==", "license": "MIT" }, "node_modules/@firebase/app-check-interop-types": { diff --git a/scripts/lib/metro-watcher-contract.mjs b/scripts/lib/metro-watcher-contract.mjs index de8e3635d..0366a511c 100644 --- a/scripts/lib/metro-watcher-contract.mjs +++ b/scripts/lib/metro-watcher-contract.mjs @@ -46,7 +46,7 @@ export function assertMetroWatcherContract(metroPackagePath) { ) assert.equal(common.typeFromStat(stat), expected) } - for (const file of ['a/b.ts', 'a\\b.ts']) { + for (const file of ['a/b.ts', String.raw`a\b.ts`]) { assert.equal(common.posixPathMatchesPattern(/\.ts$/, file), true) assert.equal(common.posixPathMatchesPattern(/\.js$/, file), false) } From a646d3cc30e85586ed55519d28a5b9a545896677 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 21:25:00 -0700 Subject: [PATCH 100/127] fix(infra): preserve development watchers on patched dependency graphs --- .sonarcloud.properties | 2 +- docs/reference/dependency-policy.md | 21 +- governance/dependency-release-policy.json | 36 +- governance/repository-health/exceptions.json | 19 + governance/service-runtime-copy-policy.json | 18 +- infra/uhrp-server-basic/README.md | 20 + infra/uhrp-server-basic/dev/bin/nodemon.js | 48 ++ infra/uhrp-server-basic/dev/check-watch.cjs | 196 +++++++ infra/uhrp-server-basic/package-lock.json | 133 +---- infra/uhrp-server-basic/package.json | 12 +- infra/uhrp-server-cloud-bucket/README.md | 20 + .../dev/bin/nodemon.js | 48 ++ .../dev/check-watch.cjs | 196 +++++++ .../package-lock.json | 139 +---- infra/uhrp-server-cloud-bucket/package.json | 14 +- infra/wab/README.md | 20 + infra/wab/dev/bin/nodemon.js | 48 ++ infra/wab/dev/check-watch.cjs | 196 +++++++ infra/wab/package-lock.json | 504 ++++++------------ infra/wab/package.json | 16 +- .../StorageClientBase.syncTransfer.test.ts | 58 +- .../dependency-release-governance.test.mjs | 10 +- sonar-project.properties | 6 +- 23 files changed, 1176 insertions(+), 604 deletions(-) create mode 100644 infra/uhrp-server-basic/dev/bin/nodemon.js create mode 100644 infra/uhrp-server-basic/dev/check-watch.cjs create mode 100644 infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js create mode 100644 infra/uhrp-server-cloud-bucket/dev/check-watch.cjs create mode 100644 infra/wab/dev/bin/nodemon.js create mode 100644 infra/wab/dev/check-watch.cjs diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 4743f8d61..8afe3f586 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -10,7 +10,7 @@ sonar.exclusions=packages/verifast/src/wasm/bdk-core.*,conformance/generated/**, # into self-contained Docker/package build contexts and checked byte-for-byte # in CI. Analyze the code for issues, but do not report intentional generated # copies as source duplication. -sonar.cpd.exclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.man.test.ts,**/__test__/**,**/__tests__/**,**/test/**,**/tests/**,**/*.vectors.ts,**/eslint.config.js,infra/wab/src/security/rateLimitPolicy.ts,infra/uhrp-server-basic/src/security/rateLimitPolicy.ts,infra/uhrp-server-cloud-bucket/src/security/rateLimitPolicy.ts,infra/message-box-server/src/security/rateLimitPolicy.ts,infra/uhrp-server-basic/src/security/edgePolicy.ts,infra/uhrp-server-cloud-bucket/src/security/edgePolicy.ts,infra/message-box-server/src/security/edgePolicy.ts,infra/chaintracks-server/src/security/edgePolicy.ts,packages/overlays/overlay-express/src/security/edgePolicy.ts,packages/wallet/wallet-toolbox/src/storage/remoting/edgePolicy.ts,infra/uhrp-server-cloud-bucket/src/resourceLimits.ts,infra/uhrp-server-cloud-bucket/src/utils/network.ts,infra/wallet-infra/src/KnexPaymentReplayStore.ts,infra/uhrp-server-basic/src/chirp/core/**,infra/uhrp-server-cloud-bucket/src/chirp/core/**,infra/uhrp-server-basic/src/chirp/openapi.ts,infra/uhrp-server-cloud-bucket/src/chirp/openapi.ts,infra/uhrp-server-cloud-bucket/src/chirp/contracts.ts,infra/uhrp-server-cloud-bucket/src/chirp/commitIndex.ts,infra/uhrp-server-cloud-bucket/src/chirp/routes.ts,infra/uhrp-server-cloud-bucket/src/chirp/bodyMiddleware.ts,infra/chaintracks-server/src/telemetry.ts,infra/wab/src/telemetry.ts,infra/uhrp-server-basic/src/telemetry.ts,infra/uhrp-server-cloud-bucket/src/telemetry.ts,infra/wallet-infra/src/telemetry.ts,infra/message-box-server/src/telemetry.ts +sonar.cpd.exclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.man.test.ts,**/__test__/**,**/__tests__/**,**/test/**,**/tests/**,**/*.vectors.ts,**/eslint.config.js,infra/wab/src/security/rateLimitPolicy.ts,infra/uhrp-server-basic/src/security/rateLimitPolicy.ts,infra/uhrp-server-cloud-bucket/src/security/rateLimitPolicy.ts,infra/message-box-server/src/security/rateLimitPolicy.ts,infra/uhrp-server-basic/src/security/edgePolicy.ts,infra/uhrp-server-cloud-bucket/src/security/edgePolicy.ts,infra/message-box-server/src/security/edgePolicy.ts,infra/chaintracks-server/src/security/edgePolicy.ts,packages/overlays/overlay-express/src/security/edgePolicy.ts,packages/wallet/wallet-toolbox/src/storage/remoting/edgePolicy.ts,infra/uhrp-server-cloud-bucket/src/resourceLimits.ts,infra/uhrp-server-cloud-bucket/src/utils/network.ts,infra/wallet-infra/src/KnexPaymentReplayStore.ts,infra/uhrp-server-basic/src/chirp/core/**,infra/uhrp-server-cloud-bucket/src/chirp/core/**,infra/uhrp-server-basic/src/chirp/openapi.ts,infra/uhrp-server-cloud-bucket/src/chirp/openapi.ts,infra/uhrp-server-cloud-bucket/src/chirp/contracts.ts,infra/uhrp-server-cloud-bucket/src/chirp/commitIndex.ts,infra/uhrp-server-cloud-bucket/src/chirp/routes.ts,infra/uhrp-server-cloud-bucket/src/chirp/bodyMiddleware.ts,infra/chaintracks-server/src/telemetry.ts,infra/wab/src/telemetry.ts,infra/uhrp-server-basic/src/telemetry.ts,infra/uhrp-server-cloud-bucket/src/telemetry.ts,infra/wallet-infra/src/telemetry.ts,infra/message-box-server/src/telemetry.ts,infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js,infra/uhrp-server-cloud-bucket/dev/check-watch.cjs,infra/wab/dev/bin/nodemon.js,infra/wab/dev/check-watch.cjs # Narrow compatibility exceptions are registered with owner, evidence, review # dates, and objective removal conditions in repository-health/exceptions.json. sonar.issue.ignore.multicriteria=werrProtocolNames,curveSingletonAlias,curveSingletonReturn,scriptOpcodeDispatch diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index 26bdbaa77..765cc0b2a 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -139,6 +139,25 @@ all three together when an official compatible release removes the affected path and the full compatibility, frozen graph, audit and platform checks pass. This build-tool repair does not change published wallet APIs or package versions. +## Temporary standalone service watcher repair + +The basic and cloud UHRP services and WAB use Nodemon 3.1.14 with a parent-scoped +Chokidar 4.0.3 substitution, removing the same affected braces closure without +an advisory exclusion. Nodemon already resolves watched globs to literal +directories; the source-owned adapter retains the old anymatch 3 ignore +semantics, including recursive literal directories, custom cwd, dotfiles, +regex and function options. WAB replaces ts-node-dev with this same CLI. +The existing Node/ts-node/telemetry execution paths remain intact, with explicit +TypeScript and env extensions, manual `rs` and graceful shutdown regressions +run before each service's ordinary tests. The service-copy policy keeps both +adapter and native regression identical across all three service contexts. + +The three new registered substitutions bring the combined retained count to 30. Remove them and the adapter together after a compatible official Nodemon +release resolves the dependency path natively and all watcher, frozen audit, +service and protected Linux image gates pass. These standalone development +tools change no public npm candidate version, service HTTP API, production +startup or persisted schema; deployed images require separate promotion. + ## Supply-chain controls `pnpm-workspace.yaml` is the source of truth for installation controls: @@ -219,7 +238,7 @@ compatibility checks, not a throughput or memory benchmark. No public npm version or consumer migration changes; protected Linux image and exact-head analysis gates still qualify the eventual service artifacts before promotion. -The root workspace carries 26 audited dependency overrides, including: +The combined workspace and standalone registries carry 30 audited dependency overrides, including: - Jest 30.5.1 and Stryker still constrain parts of their reporting and coverage graphs to minimatch releases with older `brace-expansion` ranges. The follow-up diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index 696e9b955..c31fffbef 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-10-01", + "lastReviewed": "2026-10-03", "owner": "ts-stack-maintainers", "routineUpdates": { "dependabotConfig": ".github/dependabot.yml", @@ -144,11 +144,11 @@ "closeImplementationWaveAfterPublication": false }, "overrideRemovalReview": { - "reviewedAt": "2026-10-01", - "method": "Rechecked every registered substitution after the workspace brace-expansion floor moved from 5.0.9 to 5.0.12 and engine.io 6.6.10 was added. Jest and Stryker minimatch still admit brace-expansion below 5.0.12, and socket.io in authsocket still admits engine.io below 6.6.10. Both substitutions stay on the first release that clears the current high advisories. The other 23 selectors, including the Metro-scoped image-size 2.0.4 substitution, remain necessary against the frozen graph. On 2026-10-02 the lodash-es 4.18.1 selector was added because Mermaid 12 brings chevrotain 11.1.2, which pins vulnerable lodash-es 4.17.23. The yamux-scoped @libp2p/utils 7.4.1 selector was added so YamuxStream satisfies @libp2p/interface 3.3 readableEnded. Metro 0.87.1 replaced its image-size dependency with an in-tree parser, so the Metro-scoped image-size substitution was retired on 2026-10-02. Reconciliation with #569 retains its previously qualified 5.0.12 floor for GHSA-q2hr-2g5m-vwhr in both workspace and standalone graphs. On 2026-10-02, no released Metro/file-map/micromatch/braces upgrade removes high GHSA-vfj7-8cjw-p6xm. The exact Metro-file-map0.87.1 watcher patch uses its existing Picomatch2.3.2 some() matcher and removes only scoped micromatch and its unused braces closure; all importers/settings and other package resolutions remain unchanged.", - "retainedCount": 27, - "result": "All 27 retained overrides prevent a reproduced vulnerable transitive version, keep a stream type assignable, or preserve an isolated reproducible toolchain closure. The workspace brace-expansion selector is now 5.0.12, covering GHSA-6j4f-fj2g-mc7p and GHSA-qhr7-859c-m2p7. The engine.io 6.6.10 selector closes GHSA-2gc4-cqfq-p2gv on the authsocket socket.io path without an advisory exclusion. The lodash-es 4.18.1 selector closes GHSA-r5fr-rjxr-66jc on the Mermaid/chevrotain path. The yamux-scoped @libp2p/utils 7.4.1 selector supplies readableEnded for interface 3.3. The brace-expansion floor also covers GHSA-q2hr-2g5m-vwhr; no advisory exclusion is introduced. The Metro-file-map exact-version repair removes the affected dependency closure without an audit exclusion; source-owned watcher compatibility, packed Metro/Hermes and complete affected gates remain required.", - "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch/brace-expansion/js-yaml, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO/engine.io, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, image-size, or Mermaid/chevrotain/lodash-es dependency changes. Remove the paired Metro watcher patch, exact package extension and scoped removal together once an official compatible release removes the affected closure and all watcher/platform gates pass." + "reviewedAt": "2026-10-03", + "method": "Rechecked every registered substitution after the workspace brace-expansion floor moved from 5.0.9 to 5.0.12 and engine.io 6.6.10 was added. Jest and Stryker minimatch still admit brace-expansion below 5.0.12, and socket.io in authsocket still admits engine.io below 6.6.10. Both substitutions stay on the first release that clears the current high advisories. The other 23 selectors, including the Metro-scoped image-size 2.0.4 substitution, remain necessary against the frozen graph. On 2026-10-02 the lodash-es 4.18.1 selector was added because Mermaid 12 brings chevrotain 11.1.2, which pins vulnerable lodash-es 4.17.23. The yamux-scoped @libp2p/utils 7.4.1 selector was added so YamuxStream satisfies @libp2p/interface 3.3 readableEnded. Metro 0.87.1 replaced its image-size dependency with an in-tree parser, so the Metro-scoped image-size substitution was retired on 2026-10-02. Reconciliation with #569 retains its previously qualified 5.0.12 floor for GHSA-q2hr-2g5m-vwhr in both workspace and standalone graphs. On 2026-10-02, no released Metro/file-map/micromatch/braces upgrade removes high GHSA-vfj7-8cjw-p6xm. The exact Metro-file-map0.87.1 watcher patch uses its existing Picomatch2.3.2 some() matcher and removes only scoped micromatch and its unused braces closure; all importers/settings and other package resolutions remain unchanged. On 2026-10-03, three standalone Nodemon-parent Chokidar4.0.3 substitutions remove the affected braces closure. WAB replaces ts-node-dev with the same Nodemon CLI; an owned adapter preserves the old glob-aware ignore predicate and the existing Node/ts-node/telemetry execution contracts. Native restart and shutdown regressions cover each locked service.", + "retainedCount": 30, + "result": "All 30 retained overrides prevent a reproduced vulnerable transitive version, keep a stream type assignable, or preserve an isolated reproducible toolchain closure. The workspace brace-expansion selector is now 5.0.12, covering GHSA-6j4f-fj2g-mc7p and GHSA-qhr7-859c-m2p7. The engine.io 6.6.10 selector closes GHSA-2gc4-cqfq-p2gv on the authsocket socket.io path without an advisory exclusion. The lodash-es 4.18.1 selector closes GHSA-r5fr-rjxr-66jc on the Mermaid/chevrotain path. The yamux-scoped @libp2p/utils 7.4.1 selector supplies readableEnded for interface 3.3. The brace-expansion floor also covers GHSA-q2hr-2g5m-vwhr; no advisory exclusion is introduced. The Metro-file-map exact-version repair removes the affected dependency closure without an audit exclusion; source-owned watcher compatibility, packed Metro/Hermes and complete affected gates remain required. The three scoped Nodemon substitutions remove the affected standalone watcher closure without an advisory exclusion; their source-owned compatibility adapter and complete service/hosted image gates remain required.", + "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch/brace-expansion/js-yaml, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO/engine.io, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, image-size, or Mermaid/chevrotain/lodash-es dependency changes. Remove the paired Metro watcher patch, exact package extension and scoped removal together once an official compatible release removes the affected closure and all watcher/platform gates pass. Remove the standalone Nodemon substitutions and adapter together after an official compatible Nodemon release resolves the dependency path natively and all ignore/restart/shutdown, frozen audit, service and Linux image checks pass." }, "overrideRegistry": [ { @@ -317,6 +317,30 @@ "selector": "metro-file-map@0.87.1>micromatch", "value": "'-'", "exceptionId": "metro-file-map-picomatch-advisory-repair" + }, + { + "source": "infra/uhrp-server-basic/package.json", + "selector": "nodemon", + "value": { + "chokidar": "4.0.3" + }, + "exceptionId": "standalone-nodemon-chokidar-advisory-repair" + }, + { + "source": "infra/uhrp-server-cloud-bucket/package.json", + "selector": "nodemon", + "value": { + "chokidar": "4.0.3" + }, + "exceptionId": "standalone-nodemon-chokidar-advisory-repair" + }, + { + "source": "infra/wab/package.json", + "selector": "nodemon", + "value": { + "chokidar": "4.0.3" + }, + "exceptionId": "standalone-nodemon-chokidar-advisory-repair" } ], "scheduledVerification": { diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index e8b08c55e..9b212dcce 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -433,6 +433,25 @@ "created": "2026-10-02", "reviewBy": "2026-11-01", "removeWhen": "Remove the patch, exact-version package extension and scoped removal together after an official compatible Metro/file-map release removes the affected dependency path; require the complete watcher oracle, fresh unexcluded audit, frozen graph and packed Metro/Hermes platform checks before retirement." + }, + { + "id": "standalone-nodemon-chokidar-advisory-repair", + "category": "override", + "target": "Nodemon-parent chokidar4.0.3 in infra/uhrp-server-basic, infra/uhrp-server-cloud-bucket and infra/wab", + "owner": "ts-stack-maintainers", + "reason": "No compatible released Nodemon update removes high GHSA-vfj7-8cjw-p6xm from its Chokidar3/braces graph. Nodemon already expands watch globs into literal directories. A source-owned adapter restores the former anymatch3 ignore predicate, including literal-directory recursion, custom cwd, regex/functions and dotfile behavior, while retaining the actual CLI, manual restart, Node/ts-node preloads and shutdown. WAB replaces ts-node-dev with the same tested watcher instead of forcing its unsupported glob API through Chokidar4. Only each Nodemon parent receives the substitution; production service startup and storage/HTTP contracts are unchanged. This is a temporary dependency repair, not an advisory exclusion.", + "evidence": [ + "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm", + "https://github.com/paulmillr/chokidar/releases/tag/4.0.3", + "https://github.com/remy/nodemon/blob/v3.1.14/bin/nodemon.js", + "infra/uhrp-server-basic/dev/bin/nodemon.js", + "infra/uhrp-server-basic/dev/check-watch.cjs", + "governance/service-runtime-copy-policy.json", + "https://github.com/bsv-blockchain/ts-stack/discussions/757" + ], + "created": "2026-10-03", + "reviewBy": "2026-11-03", + "removeWhen": "Remove all three scoped substitutions and the compatibility adapter together after an official compatible Nodemon release removes the affected dependency closure and preserves the full ignore/restart/preload/manual-restart/shutdown contract; require fresh frozen unexcluded audits, complete service builds/tests and protected Linux image qualification before retirement." } ] } diff --git a/governance/service-runtime-copy-policy.json b/governance/service-runtime-copy-policy.json index c5fedf61e..142d362aa 100644 --- a/governance/service-runtime-copy-policy.json +++ b/governance/service-runtime-copy-policy.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "lastReviewed": "2026-09-27", + "lastReviewed": "2026-10-03", "owner": "ts-stack-maintainers", - "rationale": "Standalone image build contexts retain a small number of runtime sources that are canonically owned elsewhere. These copies are synchronized byte-for-byte so published packages and official images cannot drift.", + "rationale": "Standalone image build contexts retain a small number of runtime sources that are canonically owned elsewhere. These copies are synchronized byte-for-byte so published packages and official images cannot drift. The same rule owns standalone development watcher adapters and their native regression tests.", "copies": [ { "canonicalSource": "infra/uhrp-server-basic/src/resourceLimits.ts", @@ -112,6 +112,20 @@ { "canonicalSource": "infra/uhrp-server-basic/src/chirp/bodyMiddleware.ts", "synchronizedSources": ["infra/uhrp-server-cloud-bucket/src/chirp/bodyMiddleware.ts"] + }, + { + "canonicalSource": "infra/uhrp-server-basic/dev/bin/nodemon.js", + "synchronizedSources": [ + "infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js", + "infra/wab/dev/bin/nodemon.js" + ] + }, + { + "canonicalSource": "infra/uhrp-server-basic/dev/check-watch.cjs", + "synchronizedSources": [ + "infra/uhrp-server-cloud-bucket/dev/check-watch.cjs", + "infra/wab/dev/check-watch.cjs" + ] } ] } diff --git a/infra/uhrp-server-basic/README.md b/infra/uhrp-server-basic/README.md index e7f933d79..db904022e 100644 --- a/infra/uhrp-server-basic/README.md +++ b/infra/uhrp-server-basic/README.md @@ -112,3 +112,23 @@ recreated the same pathname. Heartbeats keep live locks current, but an orphan left by a crashed process requires operator removal after every writer using the shared `CHIRP_DATA_DIR` has been stopped. Never delete a lock while any CHIRP replica may still be writing. + +## Development watcher + +`npm run dev` uses the locked Nodemon CLI with a source-owned Chokidar 4 +compatibility adapter. It watches TypeScript, existing JavaScript/JSON extensions, +new source files and `.env`, retains the Node/ts-node telemetry preload, and +supports manual `rs` restarts. Existing ignored glob, directory, regex and +function options retain Chokidar 3 matching behavior. WAB replaces ts-node-dev +with this same CLI; production startup, HTTP contracts and persisted data are +unchanged. No public npm package version or consumer migration is required for +these standalone service development tools. + +The parent-scoped Chokidar substitution removes the affected braces dependency +without an advisory exclusion. The dated dependency registry owns its removal +condition. `npm test` first runs the actual locked watcher regression, including +clean shutdown, using the actual service development recipe. WAB also retains +compiler-configuration restarts. The basic UHRP service owns the adapter and +regression; the root service-copy generator synchronizes cloud UHRP and WAB. +Protected Linux image and exact-head CI checks must qualify release candidates; +source changes do not update deployed images. diff --git a/infra/uhrp-server-basic/dev/bin/nodemon.js b/infra/uhrp-server-basic/dev/bin/nodemon.js new file mode 100644 index 000000000..0ceb8fa27 --- /dev/null +++ b/infra/uhrp-server-basic/dev/bin/nodemon.js @@ -0,0 +1,48 @@ +'use strict' + +const path = require('node:path') +const anymatch = require('anymatch') +const isGlob = require('is-glob') +const { bus } = require('nodemon/lib/utils') +const { rulesToMonitor } = require('nodemon/lib/monitor/match') + +function unix(value) { + const slashes = value.replace(/\\/g, '/') + return (slashes.startsWith('//') ? '/' : '') + slashes.replace(/\/{2,}/g, '/') +} +function normalizeIgnored(value, cwd) { + if (typeof value !== 'string') return value + const joined = path.isAbsolute(value) ? value : path.join(cwd ?? '', value) + return unix(path.normalize(unix(joined))) +} + +/** Nodemon already expands watched globs into literal directories. Chokidar4 + * also needs the former glob-aware ignore predicate, including literal-directory + * recursion and custom ignored functions/regexes. Retain its published anymatch + * matcher instead of the removed braces-dependent watch-path expansion. */ +function applyOptions(config) { + const options = config.options.watchOptions ?? {} + let ignored + if (Object.hasOwn(options, 'ignored')) ignored = options.ignored + else { + ignored = rulesToMonitor([], Array.from(config.options.ignore), config).map(pattern => + pattern.slice(1) + ) + const dotFile = /[/\\]\./ + if (!config.dirs.some(directory => dotFile.test(directory))) ignored.push(dotFile) + } + const normalized = (Array.isArray(ignored) ? ignored : [ignored]).map(value => + normalizeIgnored(value, options.cwd) + ) + const directories = normalized + .filter(value => typeof value === 'string' && !isGlob(value)) + .map(value => value + '/**') + const predicate = anymatch([...normalized, ...directories], undefined, { dot: true }) + config.options.watchOptions = { ...options, ignored: (file, stats) => predicate([file, stats]) } +} + +module.exports = applyOptions +if (require.main === module) { + bus.on('config:update', applyOptions) + require('nodemon/bin/nodemon.js') +} diff --git a/infra/uhrp-server-basic/dev/check-watch.cjs b/infra/uhrp-server-basic/dev/check-watch.cjs new file mode 100644 index 000000000..1d5095588 --- /dev/null +++ b/infra/uhrp-server-basic/dev/check-watch.cjs @@ -0,0 +1,196 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const { readFileSync } = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { spawn } = require('node:child_process') +const { test } = require('node:test') +const applyOptions = require('./bin/nodemon.js') +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) + +test('development ignore options retain legacy glob, literal-directory, regex, function, cwd and dotfile behavior', () => { + const cases = [ + { ignored: '/repo/cache', file: '/repo/cache/child/file.ts', expected: true }, + { ignored: '/repo/cache', file: '/repo/cache-other/file.ts', expected: false }, + { ignored: 'cache', cwd: '/repo', file: '/repo/cache/file.ts', expected: true }, + { + ignored: String.raw`cache\nested`, + cwd: '/repo', + file: '/repo/cache/nested/file.ts', + expected: true + }, + { ignored: '**/generated/**', file: '/repo/generated/file.ts', expected: true }, + { ignored: '**/generated/**', file: '/repo/src/file.ts', expected: false }, + { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/drop.ts', expected: true }, + { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/keep.ts', expected: false }, + { ignored: /\.map$/, file: '/repo/file.map', expected: true }, + { ignored: /\.map$/, file: '/repo/file.ts', expected: false }, + { + ignored: (_file, stats) => stats?.marker === true, + file: '/repo/a.ts', + stats: { marker: true }, + expected: true + }, + { ignored: (_file, stats) => stats?.marker === true, file: '/repo/a.ts', expected: false }, + { ignored: undefined, file: '/repo/a.ts', expected: false }, + { ignored: [], file: '/repo/a.ts', expected: false } + ] + for (const item of cases) { + const config = { + dirs: ['/repo/src'], + options: { + ignore: [], + watchOptions: { ignored: item.ignored, cwd: item.cwd, usePolling: true, interval: 71 } + } + } + applyOptions(config) + assert.equal( + config.options.watchOptions.ignored(item.file, item.stats), + item.expected, + item.file + ) + assert.equal(config.options.watchOptions.usePolling, true) + assert.equal(config.options.watchOptions.interval, 71) + } + const defaults = { dirs: ['/repo/src'], options: { ignore: ['**/node_modules/**'] } } + applyOptions(defaults) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/.hidden.ts'), true) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/node_modules/pkg/a.ts'), true) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/a.ts'), false) + const explicitDotfile = { dirs: ['/repo/.env', '/repo/src'], options: { ignore: [] } } + applyOptions(explicitDotfile) + assert.equal(explicitDotfile.options.watchOptions.ignored('/repo/.env'), false) +}) + +test( + 'actual locked watcher restarts TS, env and new source, retains preloads/manual restart, ignores dependencies and stops', + { timeout: 60000 }, + async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-stack-dev-watch-')) + let child, + exited, + output = '', + errors = '', + forced = false + const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] + async function waitFor(predicate, description) { + for (let i = 0; i < 400; i++) { + if (predicate()) return + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Watcher exited during ${description}: ${errors}`) + await delay(25) + } + throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + } + try { + await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) + await fs.mkdir(path.join(directory, 'src/generated'), { recursive: true }) + await fs.symlink( + path.resolve(__dirname, '../node_modules'), + path.join(directory, 'node_modules'), + 'junction' + ) + await fs.writeFile( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'commonjs', + target: 'es2022', + strict: true, + skipLibCheck: true, + types: ['node'] + } + }) + ) + await fs.writeFile(path.join(directory, '.env'), 'synthetic ordinary configuration') + await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 1') + await fs.writeFile( + path.join(directory, 'src/telemetry.ts'), + "console.log('PRELOAD:' + process.pid)" + ) + const recipe = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')) + .scripts.dev + const prefix = 'node dev/bin/nodemon.js ' + assert.ok(recipe.startsWith(prefix) && recipe.endsWith('"')) + const [watch, execution] = recipe.slice(prefix.length, -1).split(' --exec "') + assert.ok(execution) + const options = watch.split(/\s+/) + const entry = execution.match(/src\/(index|server)\.ts$/)?.[0] + assert.ok(entry) + await fs.writeFile( + path.join(directory, entry), + "import { value } from './dependency'; console.log('READY:' + process.pid + ':' + value); setInterval(() => {}, 1000)" + ) + child = spawn( + process.execPath, + [ + path.join(__dirname, 'bin/nodemon.js'), + ...options, + '--ignore', + 'src/generated/**', + '--exec', + execution + ], + { + cwd: directory, + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CI: '1', NO_UPDATE_NOTIFIER: '1' } + } + ) + exited = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + child.stdout.on('data', data => { + output += data.toString() + }) + child.stderr.on('data', data => { + errors += data.toString() + }) + await waitFor(() => starts().length === 1, 'start the synthetic TypeScript process') + await delay(500) + await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 2') + await waitFor(() => starts().length === 2, 'restart for a TypeScript dependency') + assert.equal(starts()[1][2], '2') + await delay(500) + await fs.writeFile(path.join(directory, 'src/node_modules/ignored.js'), 'module.exports = 1') + await fs.writeFile( + path.join(directory, 'src/generated/ignored.ts'), + 'export const ignored = 1' + ) + await delay(700) + assert.equal(starts().length, 2) + await fs.writeFile(path.join(directory, '.env'), 'synthetic changed configuration') + await waitFor(() => starts().length === 3, 'restart for the explicitly watched env file') + await delay(500) + await fs.writeFile(path.join(directory, 'src/new-file.ts'), 'export const newFile = 1') + await waitFor(() => starts().length === 4, 'restart for a new TypeScript source file') + child.stdin.write('rs\n') + await waitFor(() => starts().length === 5, 'accept a manual restart') + if (options.includes('tsconfig.json')) { + await delay(500) + await fs.appendFile(path.join(directory, 'tsconfig.json'), '\n') + await waitFor(() => starts().length === 6, 'restart for the watched compiler configuration') + } + assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + process.kill(-child.pid, 'SIGTERM') + const escalation = setTimeout(() => { + forced = true + process.kill(-child.pid, 'SIGKILL') + }, 5000) + try { + await exited + } finally { + clearTimeout(escalation) + } + } + await fs.rm(directory, { recursive: true, force: true }) + } + assert.equal(forced, false, 'the CLI and owned child must exit without forced termination') + } +) diff --git a/infra/uhrp-server-basic/package-lock.json b/infra/uhrp-server-basic/package-lock.json index 25f7c9e83..7084da903 100644 --- a/infra/uhrp-server-basic/package-lock.json +++ b/infra/uhrp-server-basic/package-lock.json @@ -40,6 +40,8 @@ "@types/node": "^26.6.1", "@types/prettyjson": "^0.0.33", "@typescript/native": "npm:typescript@7.0.2", + "anymatch": "^3.1.3", + "is-glob": "^4.0.3", "jest": "^30.5.1", "nodemon": "^3.1.14", "oxlint": "^1.83.0", @@ -4714,18 +4716,6 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -4776,18 +4766,6 @@ "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.29.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", @@ -4937,27 +4915,19 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, + "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" } }, "node_modules/ci-info": { @@ -5547,18 +5517,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -5716,20 +5674,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5883,18 +5827,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", @@ -6125,18 +6057,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6176,15 +6096,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -7949,15 +7860,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, + "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/real-require": { @@ -8550,18 +8463,6 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/infra/uhrp-server-basic/package.json b/infra/uhrp-server-basic/package.json index d0d3e4fb2..8aa9e6923 100644 --- a/infra/uhrp-server-basic/package.json +++ b/infra/uhrp-server-basic/package.json @@ -3,7 +3,10 @@ "version": "0.1.46", "overrides": { "brace-expansion": "5.0.12", - "gaxios": "7.3.0" + "gaxios": "7.3.0", + "nodemon": { + "chokidar": "4.0.3" + } }, "engines": { "node": ">=24 <25", @@ -58,11 +61,12 @@ "scripts": { "start": "ts-node -r ./src/telemetry.ts src/index.ts", "start:prod": "node --require ./out/src/telemetry.js out/src/index.js", - "dev": "nodemon --watch .env --watch src --exec \"node --inspect=0.0.0.0 -r ts-node/register -r ./src/telemetry.ts src/index.ts\"", + "dev": "node dev/bin/nodemon.js --ext ts,js,mjs,cjs,json,env --watch .env --watch src --exec \"node --inspect=0.0.0.0 -r ts-node/register -r ./src/telemetry.ts src/index.ts\"", "build": "tsc", "test": "jest", "test:watch": "jest --watch", - "lint": "oxlint src --deny-warnings" + "lint": "oxlint src dev --deny-warnings", + "pretest": "node --test dev/check-watch.cjs" }, "devDependencies": { "@types/body-parser": "^1.19.6", @@ -70,6 +74,8 @@ "@types/node": "^26.6.1", "@types/prettyjson": "^0.0.33", "@typescript/native": "npm:typescript@7.0.2", + "anymatch": "^3.1.3", + "is-glob": "^4.0.3", "jest": "^30.5.1", "nodemon": "^3.1.14", "oxlint": "^1.83.0", diff --git a/infra/uhrp-server-cloud-bucket/README.md b/infra/uhrp-server-cloud-bucket/README.md index 1ac1195f1..eeb948f87 100644 --- a/infra/uhrp-server-cloud-bucket/README.md +++ b/infra/uhrp-server-cloud-bucket/README.md @@ -409,3 +409,23 @@ route, and failure-path checks above pass. © 2025 – Feel free to adapt, improve, and PR! Advertisement, owner metadata and renewal operations use the same `GCP_STORAGE_CREDS`/`GCP_PROJECT_ID` identity as signed uploads. When credentials are explicitly configured, those operations must not fall back to the runtime metadata server. Unset credentials retain ADC for installations that intentionally use a runtime service account. Malformed configured credentials fail without logging their contents. + +## Development watcher + +`npm run dev` uses the locked Nodemon CLI with a source-owned Chokidar 4 +compatibility adapter. It watches TypeScript, existing JavaScript/JSON extensions, +new source files and `.env`, retains the Node/ts-node telemetry preload, and +supports manual `rs` restarts. Existing ignored glob, directory, regex and +function options retain Chokidar 3 matching behavior. WAB replaces ts-node-dev +with this same CLI; production startup, HTTP contracts and persisted data are +unchanged. No public npm package version or consumer migration is required for +these standalone service development tools. + +The parent-scoped Chokidar substitution removes the affected braces dependency +without an advisory exclusion. The dated dependency registry owns its removal +condition. `npm test` first runs the actual locked watcher regression, including +clean shutdown, using the actual service development recipe. WAB also retains +compiler-configuration restarts. The basic UHRP service owns the adapter and +regression; the root service-copy generator synchronizes cloud UHRP and WAB. +Protected Linux image and exact-head CI checks must qualify release candidates; +source changes do not update deployed images. diff --git a/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js b/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js new file mode 100644 index 000000000..0ceb8fa27 --- /dev/null +++ b/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js @@ -0,0 +1,48 @@ +'use strict' + +const path = require('node:path') +const anymatch = require('anymatch') +const isGlob = require('is-glob') +const { bus } = require('nodemon/lib/utils') +const { rulesToMonitor } = require('nodemon/lib/monitor/match') + +function unix(value) { + const slashes = value.replace(/\\/g, '/') + return (slashes.startsWith('//') ? '/' : '') + slashes.replace(/\/{2,}/g, '/') +} +function normalizeIgnored(value, cwd) { + if (typeof value !== 'string') return value + const joined = path.isAbsolute(value) ? value : path.join(cwd ?? '', value) + return unix(path.normalize(unix(joined))) +} + +/** Nodemon already expands watched globs into literal directories. Chokidar4 + * also needs the former glob-aware ignore predicate, including literal-directory + * recursion and custom ignored functions/regexes. Retain its published anymatch + * matcher instead of the removed braces-dependent watch-path expansion. */ +function applyOptions(config) { + const options = config.options.watchOptions ?? {} + let ignored + if (Object.hasOwn(options, 'ignored')) ignored = options.ignored + else { + ignored = rulesToMonitor([], Array.from(config.options.ignore), config).map(pattern => + pattern.slice(1) + ) + const dotFile = /[/\\]\./ + if (!config.dirs.some(directory => dotFile.test(directory))) ignored.push(dotFile) + } + const normalized = (Array.isArray(ignored) ? ignored : [ignored]).map(value => + normalizeIgnored(value, options.cwd) + ) + const directories = normalized + .filter(value => typeof value === 'string' && !isGlob(value)) + .map(value => value + '/**') + const predicate = anymatch([...normalized, ...directories], undefined, { dot: true }) + config.options.watchOptions = { ...options, ignored: (file, stats) => predicate([file, stats]) } +} + +module.exports = applyOptions +if (require.main === module) { + bus.on('config:update', applyOptions) + require('nodemon/bin/nodemon.js') +} diff --git a/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs b/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs new file mode 100644 index 000000000..1d5095588 --- /dev/null +++ b/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs @@ -0,0 +1,196 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const { readFileSync } = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { spawn } = require('node:child_process') +const { test } = require('node:test') +const applyOptions = require('./bin/nodemon.js') +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) + +test('development ignore options retain legacy glob, literal-directory, regex, function, cwd and dotfile behavior', () => { + const cases = [ + { ignored: '/repo/cache', file: '/repo/cache/child/file.ts', expected: true }, + { ignored: '/repo/cache', file: '/repo/cache-other/file.ts', expected: false }, + { ignored: 'cache', cwd: '/repo', file: '/repo/cache/file.ts', expected: true }, + { + ignored: String.raw`cache\nested`, + cwd: '/repo', + file: '/repo/cache/nested/file.ts', + expected: true + }, + { ignored: '**/generated/**', file: '/repo/generated/file.ts', expected: true }, + { ignored: '**/generated/**', file: '/repo/src/file.ts', expected: false }, + { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/drop.ts', expected: true }, + { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/keep.ts', expected: false }, + { ignored: /\.map$/, file: '/repo/file.map', expected: true }, + { ignored: /\.map$/, file: '/repo/file.ts', expected: false }, + { + ignored: (_file, stats) => stats?.marker === true, + file: '/repo/a.ts', + stats: { marker: true }, + expected: true + }, + { ignored: (_file, stats) => stats?.marker === true, file: '/repo/a.ts', expected: false }, + { ignored: undefined, file: '/repo/a.ts', expected: false }, + { ignored: [], file: '/repo/a.ts', expected: false } + ] + for (const item of cases) { + const config = { + dirs: ['/repo/src'], + options: { + ignore: [], + watchOptions: { ignored: item.ignored, cwd: item.cwd, usePolling: true, interval: 71 } + } + } + applyOptions(config) + assert.equal( + config.options.watchOptions.ignored(item.file, item.stats), + item.expected, + item.file + ) + assert.equal(config.options.watchOptions.usePolling, true) + assert.equal(config.options.watchOptions.interval, 71) + } + const defaults = { dirs: ['/repo/src'], options: { ignore: ['**/node_modules/**'] } } + applyOptions(defaults) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/.hidden.ts'), true) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/node_modules/pkg/a.ts'), true) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/a.ts'), false) + const explicitDotfile = { dirs: ['/repo/.env', '/repo/src'], options: { ignore: [] } } + applyOptions(explicitDotfile) + assert.equal(explicitDotfile.options.watchOptions.ignored('/repo/.env'), false) +}) + +test( + 'actual locked watcher restarts TS, env and new source, retains preloads/manual restart, ignores dependencies and stops', + { timeout: 60000 }, + async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-stack-dev-watch-')) + let child, + exited, + output = '', + errors = '', + forced = false + const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] + async function waitFor(predicate, description) { + for (let i = 0; i < 400; i++) { + if (predicate()) return + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Watcher exited during ${description}: ${errors}`) + await delay(25) + } + throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + } + try { + await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) + await fs.mkdir(path.join(directory, 'src/generated'), { recursive: true }) + await fs.symlink( + path.resolve(__dirname, '../node_modules'), + path.join(directory, 'node_modules'), + 'junction' + ) + await fs.writeFile( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'commonjs', + target: 'es2022', + strict: true, + skipLibCheck: true, + types: ['node'] + } + }) + ) + await fs.writeFile(path.join(directory, '.env'), 'synthetic ordinary configuration') + await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 1') + await fs.writeFile( + path.join(directory, 'src/telemetry.ts'), + "console.log('PRELOAD:' + process.pid)" + ) + const recipe = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')) + .scripts.dev + const prefix = 'node dev/bin/nodemon.js ' + assert.ok(recipe.startsWith(prefix) && recipe.endsWith('"')) + const [watch, execution] = recipe.slice(prefix.length, -1).split(' --exec "') + assert.ok(execution) + const options = watch.split(/\s+/) + const entry = execution.match(/src\/(index|server)\.ts$/)?.[0] + assert.ok(entry) + await fs.writeFile( + path.join(directory, entry), + "import { value } from './dependency'; console.log('READY:' + process.pid + ':' + value); setInterval(() => {}, 1000)" + ) + child = spawn( + process.execPath, + [ + path.join(__dirname, 'bin/nodemon.js'), + ...options, + '--ignore', + 'src/generated/**', + '--exec', + execution + ], + { + cwd: directory, + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CI: '1', NO_UPDATE_NOTIFIER: '1' } + } + ) + exited = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + child.stdout.on('data', data => { + output += data.toString() + }) + child.stderr.on('data', data => { + errors += data.toString() + }) + await waitFor(() => starts().length === 1, 'start the synthetic TypeScript process') + await delay(500) + await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 2') + await waitFor(() => starts().length === 2, 'restart for a TypeScript dependency') + assert.equal(starts()[1][2], '2') + await delay(500) + await fs.writeFile(path.join(directory, 'src/node_modules/ignored.js'), 'module.exports = 1') + await fs.writeFile( + path.join(directory, 'src/generated/ignored.ts'), + 'export const ignored = 1' + ) + await delay(700) + assert.equal(starts().length, 2) + await fs.writeFile(path.join(directory, '.env'), 'synthetic changed configuration') + await waitFor(() => starts().length === 3, 'restart for the explicitly watched env file') + await delay(500) + await fs.writeFile(path.join(directory, 'src/new-file.ts'), 'export const newFile = 1') + await waitFor(() => starts().length === 4, 'restart for a new TypeScript source file') + child.stdin.write('rs\n') + await waitFor(() => starts().length === 5, 'accept a manual restart') + if (options.includes('tsconfig.json')) { + await delay(500) + await fs.appendFile(path.join(directory, 'tsconfig.json'), '\n') + await waitFor(() => starts().length === 6, 'restart for the watched compiler configuration') + } + assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + process.kill(-child.pid, 'SIGTERM') + const escalation = setTimeout(() => { + forced = true + process.kill(-child.pid, 'SIGKILL') + }, 5000) + try { + await exited + } finally { + clearTimeout(escalation) + } + } + await fs.rm(directory, { recursive: true, force: true }) + } + assert.equal(forced, false, 'the CLI and owned child must exit without forced termination') + } +) diff --git a/infra/uhrp-server-cloud-bucket/package-lock.json b/infra/uhrp-server-cloud-bucket/package-lock.json index 4c530cf5e..ef8716687 100644 --- a/infra/uhrp-server-cloud-bucket/package-lock.json +++ b/infra/uhrp-server-cloud-bucket/package-lock.json @@ -47,7 +47,9 @@ "@types/node": "^26.6.1", "@types/prettyjson": "^0.0.33", "@typescript/native": "npm:typescript@7.0.2", + "anymatch": "^3.1.3", "ejs": "^6.0.1", + "is-glob": "^4.0.3", "jest": "^30.5.1", "nodemon": "^3.1.14", "oxlint": "^1.83.0", @@ -5147,19 +5149,6 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -5233,19 +5222,6 @@ "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -5425,28 +5401,19 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" } }, "node_modules/ci-info": { @@ -6238,19 +6205,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -6411,21 +6365,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6574,19 +6513,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/google-auth-library": { "version": "9.15.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", @@ -6937,19 +6863,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -7007,16 +6920,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -8962,16 +8865,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/real-require": { @@ -9843,19 +9747,6 @@ "node": ">=8" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/infra/uhrp-server-cloud-bucket/package.json b/infra/uhrp-server-cloud-bucket/package.json index 4f1783ec8..3c0811ea8 100644 --- a/infra/uhrp-server-cloud-bucket/package.json +++ b/infra/uhrp-server-cloud-bucket/package.json @@ -4,7 +4,10 @@ "overrides": { "brace-expansion": "5.0.12", "gaxios": "7.3.0", - "uuid": "11.1.1" + "uuid": "11.1.1", + "nodemon": { + "chokidar": "4.0.3" + } }, "engines": { "node": ">=24 <25", @@ -64,13 +67,14 @@ "scripts": { "start": "ts-node -r ./src/telemetry.ts src/index.ts", "start:prod": "node --require ./out/src/telemetry.js --max-http-header-size=512000 out/src/index.js", - "dev": "nodemon --watch .env --watch src --exec \"node --inspect=0.0.0.0 -r ts-node/register -r ./src/telemetry.ts src/index.ts\"", + "dev": "node dev/bin/nodemon.js --ext ts,js,mjs,cjs,json,env --watch .env --watch src --exec \"node --inspect=0.0.0.0 -r ts-node/register -r ./src/telemetry.ts src/index.ts\"", "build": "tsc", "test": "jest", "test:watch": "jest --watch", - "lint": "oxlint src scripts --deny-warnings", + "lint": "oxlint src scripts dev --deny-warnings", "secrets:staging": "ts-node scripts/sync-secrets.ts --env staging --create-env", - "secrets:prod": "ts-node scripts/sync-secrets.ts --env prod --create-env" + "secrets:prod": "ts-node scripts/sync-secrets.ts --env prod --create-env", + "pretest": "node --test dev/check-watch.cjs" }, "devDependencies": { "@types/body-parser": "^1.19.6", @@ -80,7 +84,9 @@ "@types/node": "^26.6.1", "@types/prettyjson": "^0.0.33", "@typescript/native": "npm:typescript@7.0.2", + "anymatch": "^3.1.3", "ejs": "^6.0.1", + "is-glob": "^4.0.3", "jest": "^30.5.1", "nodemon": "^3.1.14", "oxlint": "^1.83.0", diff --git a/infra/wab/README.md b/infra/wab/README.md index 1fcf4ed0e..5bbae44fd 100644 --- a/infra/wab/README.md +++ b/infra/wab/README.md @@ -664,3 +664,23 @@ To contribute: ## License This project is available under the [Open BSV License Version 6](./LICENSE.txt). + +## Development watcher + +`npm run dev` uses the locked Nodemon CLI with a source-owned Chokidar 4 +compatibility adapter. It watches TypeScript, existing JavaScript/JSON extensions, +new source files and `.env`, retains the Node/ts-node telemetry preload, and +supports manual `rs` restarts. Existing ignored glob, directory, regex and +function options retain Chokidar 3 matching behavior. WAB replaces ts-node-dev +with this same CLI; production startup, HTTP contracts and persisted data are +unchanged. No public npm package version or consumer migration is required for +these standalone service development tools. + +The parent-scoped Chokidar substitution removes the affected braces dependency +without an advisory exclusion. The dated dependency registry owns its removal +condition. `npm test` first runs the actual locked watcher regression, including +clean shutdown, using the actual service development recipe. WAB also retains +compiler-configuration restarts. The basic UHRP service owns the adapter and +regression; the root service-copy generator synchronizes cloud UHRP and WAB. +Protected Linux image and exact-head CI checks must qualify release candidates; +source changes do not update deployed images. diff --git a/infra/wab/dev/bin/nodemon.js b/infra/wab/dev/bin/nodemon.js new file mode 100644 index 000000000..0ceb8fa27 --- /dev/null +++ b/infra/wab/dev/bin/nodemon.js @@ -0,0 +1,48 @@ +'use strict' + +const path = require('node:path') +const anymatch = require('anymatch') +const isGlob = require('is-glob') +const { bus } = require('nodemon/lib/utils') +const { rulesToMonitor } = require('nodemon/lib/monitor/match') + +function unix(value) { + const slashes = value.replace(/\\/g, '/') + return (slashes.startsWith('//') ? '/' : '') + slashes.replace(/\/{2,}/g, '/') +} +function normalizeIgnored(value, cwd) { + if (typeof value !== 'string') return value + const joined = path.isAbsolute(value) ? value : path.join(cwd ?? '', value) + return unix(path.normalize(unix(joined))) +} + +/** Nodemon already expands watched globs into literal directories. Chokidar4 + * also needs the former glob-aware ignore predicate, including literal-directory + * recursion and custom ignored functions/regexes. Retain its published anymatch + * matcher instead of the removed braces-dependent watch-path expansion. */ +function applyOptions(config) { + const options = config.options.watchOptions ?? {} + let ignored + if (Object.hasOwn(options, 'ignored')) ignored = options.ignored + else { + ignored = rulesToMonitor([], Array.from(config.options.ignore), config).map(pattern => + pattern.slice(1) + ) + const dotFile = /[/\\]\./ + if (!config.dirs.some(directory => dotFile.test(directory))) ignored.push(dotFile) + } + const normalized = (Array.isArray(ignored) ? ignored : [ignored]).map(value => + normalizeIgnored(value, options.cwd) + ) + const directories = normalized + .filter(value => typeof value === 'string' && !isGlob(value)) + .map(value => value + '/**') + const predicate = anymatch([...normalized, ...directories], undefined, { dot: true }) + config.options.watchOptions = { ...options, ignored: (file, stats) => predicate([file, stats]) } +} + +module.exports = applyOptions +if (require.main === module) { + bus.on('config:update', applyOptions) + require('nodemon/bin/nodemon.js') +} diff --git a/infra/wab/dev/check-watch.cjs b/infra/wab/dev/check-watch.cjs new file mode 100644 index 000000000..1d5095588 --- /dev/null +++ b/infra/wab/dev/check-watch.cjs @@ -0,0 +1,196 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const { readFileSync } = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { spawn } = require('node:child_process') +const { test } = require('node:test') +const applyOptions = require('./bin/nodemon.js') +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) + +test('development ignore options retain legacy glob, literal-directory, regex, function, cwd and dotfile behavior', () => { + const cases = [ + { ignored: '/repo/cache', file: '/repo/cache/child/file.ts', expected: true }, + { ignored: '/repo/cache', file: '/repo/cache-other/file.ts', expected: false }, + { ignored: 'cache', cwd: '/repo', file: '/repo/cache/file.ts', expected: true }, + { + ignored: String.raw`cache\nested`, + cwd: '/repo', + file: '/repo/cache/nested/file.ts', + expected: true + }, + { ignored: '**/generated/**', file: '/repo/generated/file.ts', expected: true }, + { ignored: '**/generated/**', file: '/repo/src/file.ts', expected: false }, + { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/drop.ts', expected: true }, + { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/keep.ts', expected: false }, + { ignored: /\.map$/, file: '/repo/file.map', expected: true }, + { ignored: /\.map$/, file: '/repo/file.ts', expected: false }, + { + ignored: (_file, stats) => stats?.marker === true, + file: '/repo/a.ts', + stats: { marker: true }, + expected: true + }, + { ignored: (_file, stats) => stats?.marker === true, file: '/repo/a.ts', expected: false }, + { ignored: undefined, file: '/repo/a.ts', expected: false }, + { ignored: [], file: '/repo/a.ts', expected: false } + ] + for (const item of cases) { + const config = { + dirs: ['/repo/src'], + options: { + ignore: [], + watchOptions: { ignored: item.ignored, cwd: item.cwd, usePolling: true, interval: 71 } + } + } + applyOptions(config) + assert.equal( + config.options.watchOptions.ignored(item.file, item.stats), + item.expected, + item.file + ) + assert.equal(config.options.watchOptions.usePolling, true) + assert.equal(config.options.watchOptions.interval, 71) + } + const defaults = { dirs: ['/repo/src'], options: { ignore: ['**/node_modules/**'] } } + applyOptions(defaults) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/.hidden.ts'), true) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/node_modules/pkg/a.ts'), true) + assert.equal(defaults.options.watchOptions.ignored('/repo/src/a.ts'), false) + const explicitDotfile = { dirs: ['/repo/.env', '/repo/src'], options: { ignore: [] } } + applyOptions(explicitDotfile) + assert.equal(explicitDotfile.options.watchOptions.ignored('/repo/.env'), false) +}) + +test( + 'actual locked watcher restarts TS, env and new source, retains preloads/manual restart, ignores dependencies and stops', + { timeout: 60000 }, + async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-stack-dev-watch-')) + let child, + exited, + output = '', + errors = '', + forced = false + const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] + async function waitFor(predicate, description) { + for (let i = 0; i < 400; i++) { + if (predicate()) return + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Watcher exited during ${description}: ${errors}`) + await delay(25) + } + throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + } + try { + await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) + await fs.mkdir(path.join(directory, 'src/generated'), { recursive: true }) + await fs.symlink( + path.resolve(__dirname, '../node_modules'), + path.join(directory, 'node_modules'), + 'junction' + ) + await fs.writeFile( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'commonjs', + target: 'es2022', + strict: true, + skipLibCheck: true, + types: ['node'] + } + }) + ) + await fs.writeFile(path.join(directory, '.env'), 'synthetic ordinary configuration') + await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 1') + await fs.writeFile( + path.join(directory, 'src/telemetry.ts'), + "console.log('PRELOAD:' + process.pid)" + ) + const recipe = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')) + .scripts.dev + const prefix = 'node dev/bin/nodemon.js ' + assert.ok(recipe.startsWith(prefix) && recipe.endsWith('"')) + const [watch, execution] = recipe.slice(prefix.length, -1).split(' --exec "') + assert.ok(execution) + const options = watch.split(/\s+/) + const entry = execution.match(/src\/(index|server)\.ts$/)?.[0] + assert.ok(entry) + await fs.writeFile( + path.join(directory, entry), + "import { value } from './dependency'; console.log('READY:' + process.pid + ':' + value); setInterval(() => {}, 1000)" + ) + child = spawn( + process.execPath, + [ + path.join(__dirname, 'bin/nodemon.js'), + ...options, + '--ignore', + 'src/generated/**', + '--exec', + execution + ], + { + cwd: directory, + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CI: '1', NO_UPDATE_NOTIFIER: '1' } + } + ) + exited = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + child.stdout.on('data', data => { + output += data.toString() + }) + child.stderr.on('data', data => { + errors += data.toString() + }) + await waitFor(() => starts().length === 1, 'start the synthetic TypeScript process') + await delay(500) + await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 2') + await waitFor(() => starts().length === 2, 'restart for a TypeScript dependency') + assert.equal(starts()[1][2], '2') + await delay(500) + await fs.writeFile(path.join(directory, 'src/node_modules/ignored.js'), 'module.exports = 1') + await fs.writeFile( + path.join(directory, 'src/generated/ignored.ts'), + 'export const ignored = 1' + ) + await delay(700) + assert.equal(starts().length, 2) + await fs.writeFile(path.join(directory, '.env'), 'synthetic changed configuration') + await waitFor(() => starts().length === 3, 'restart for the explicitly watched env file') + await delay(500) + await fs.writeFile(path.join(directory, 'src/new-file.ts'), 'export const newFile = 1') + await waitFor(() => starts().length === 4, 'restart for a new TypeScript source file') + child.stdin.write('rs\n') + await waitFor(() => starts().length === 5, 'accept a manual restart') + if (options.includes('tsconfig.json')) { + await delay(500) + await fs.appendFile(path.join(directory, 'tsconfig.json'), '\n') + await waitFor(() => starts().length === 6, 'restart for the watched compiler configuration') + } + assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + process.kill(-child.pid, 'SIGTERM') + const escalation = setTimeout(() => { + forced = true + process.kill(-child.pid, 'SIGKILL') + }, 5000) + try { + await exited + } finally { + clearTimeout(escalation) + } + } + await fs.rm(directory, { recursive: true, force: true }) + } + assert.equal(forced, false, 'the CLI and owned child must exit without forced termination') + } +) diff --git a/infra/wab/package-lock.json b/infra/wab/package-lock.json index 06bae55de..e27f4b526 100644 --- a/infra/wab/package-lock.json +++ b/infra/wab/package-lock.json @@ -41,12 +41,14 @@ "@types/jest": "^30.0.0", "@types/node": "^26.6.1", "@typescript/native": "npm:typescript@7.0.2", + "anymatch": "^3.1.3", "body-parser": "^2.3.0", + "is-glob": "^4.0.3", "jest": "^30.5.1", + "nodemon": "^3.1.14", "oxlint": "^1.83.0", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", - "ts-node-dev": "^2.0.0", "typescript": "npm:@typescript/typescript6@6.0.2" }, "engines": { @@ -3773,18 +3775,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", - "dev": true - }, - "node_modules/@types/strip-json-comments": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", - "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", - "dev": true - }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -4912,18 +4902,6 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -4992,18 +4970,6 @@ "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -5088,12 +5054,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5216,30 +5176,6 @@ "node": ">=10" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -5574,15 +5510,6 @@ "node": ">= 0.4" } }, - "node_modules/dynamic-dedupe": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", - "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", - "dev": true, - "dependencies": { - "xtend": "^4.0.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5987,18 +5914,6 @@ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -6139,26 +6054,6 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6311,39 +6206,6 @@ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", @@ -6547,6 +6409,13 @@ } ] }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/import-in-the-middle": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.2.tgz", @@ -6590,17 +6459,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6643,18 +6501,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -6708,15 +6554,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -7951,19 +7788,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -7993,18 +7817,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -8239,6 +8051,117 @@ "node": ">=18" } }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/nopt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", @@ -8486,15 +8409,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8816,6 +8730,13 @@ "node": ">=10" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -8922,31 +8843,6 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -9339,6 +9235,32 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9366,16 +9288,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -9832,18 +9744,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -9852,13 +9752,14 @@ "node": ">=0.6" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", "dev": true, + "license": "ISC", "bin": { - "tree-kill": "cli.js" + "nodetouch": "bin/nodetouch.js" } }, "node_modules/ts-jest": { @@ -9984,84 +9885,6 @@ } } }, - "node_modules/ts-node-dev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", - "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.1", - "dynamic-dedupe": "^0.3.0", - "minimist": "^1.2.6", - "mkdirp": "^1.0.4", - "resolve": "^1.0.0", - "rimraf": "^2.6.1", - "source-map-support": "^0.5.12", - "tree-kill": "^1.2.2", - "ts-node": "^10.4.0", - "tsconfig": "^7.0.0" - }, - "bin": { - "ts-node-dev": "lib/bin.js", - "tsnd": "lib/bin.js" - }, - "engines": { - "node": ">=0.8.0" - }, - "peerDependencies": { - "node-notifier": "*", - "typescript": "*" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/ts-node-dev/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/tsconfig": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", - "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", - "dev": true, - "dependencies": { - "@types/strip-bom": "^3.0.0", - "@types/strip-json-comments": "0.0.30", - "strip-bom": "^3.0.0", - "strip-json-comments": "^2.0.0" - } - }, - "node_modules/tsconfig/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/tsconfig/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -10206,6 +10029,13 @@ "node": ">=0.8.0" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici": { "version": "6.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", diff --git a/infra/wab/package.json b/infra/wab/package.json index 331a015a6..67d808eb3 100644 --- a/infra/wab/package.json +++ b/infra/wab/package.json @@ -6,7 +6,10 @@ "overrides": { "brace-expansion": "5.0.12", "gaxios": "7.3.0", - "js-yaml": "3.15.2" + "js-yaml": "3.15.2", + "nodemon": { + "chokidar": "4.0.3" + } }, "engines": { "node": ">=24 <25", @@ -16,12 +19,13 @@ "main": "dist/server.js", "scripts": { "start": "tsc && node --require ./dist/telemetry.js dist/server.js", - "dev": "ts-node-dev --respawn --require ./src/telemetry.ts src/server.ts", + "dev": "node dev/bin/nodemon.js --ext ts,js,mjs,cjs,json,env --watch .env --watch src --watch tsconfig.json --exec \"node -r ts-node/register -r ./src/telemetry.ts src/server.ts\"", "build": "tsc", - "lint": "oxlint src --deny-warnings", + "lint": "oxlint src dev --deny-warnings", "migrate": "knex --knexfile knexfile.ts migrate:latest", "test": "jest", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "pretest": "node --test dev/check-watch.cjs" }, "dependencies": { "@bsv/sdk": "^2.8.11", @@ -56,12 +60,14 @@ "@types/jest": "^30.0.0", "@types/node": "^26.6.1", "@typescript/native": "npm:typescript@7.0.2", + "anymatch": "^3.1.3", "body-parser": "^2.3.0", + "is-glob": "^4.0.3", "jest": "^30.5.1", + "nodemon": "^3.1.14", "oxlint": "^1.83.0", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", - "ts-node-dev": "^2.0.0", "typescript": "npm:@typescript/typescript6@6.0.2" }, "license": "SEE LICENSE IN LICENSE.txt" diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.syncTransfer.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.syncTransfer.test.ts index a9c5f3647..eb6b92af3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.syncTransfer.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClientBase.syncTransfer.test.ts @@ -37,10 +37,10 @@ const chunk = (): SyncChunk => ({ class TransferClient extends StorageClientBase { readonly request = jest.fn, [string, unknown[]]>() - constructor() { - super({} as WalletInterface, 'https://storage.example.test', { binaryRequests: true }) + constructor(binaryRequests = true, serverSupportsBinary = true) { + super({} as WalletInterface, 'https://storage.example.test', { binaryRequests }) Reflect.set(this, 'settings', { syncTransfer: capabilities }) - this.serverSupportsBinary = true + this.serverSupportsBinary = serverSupportsBinary } protected async rpcCall(method: string, params: unknown[]): Promise { return (await this.request(method, params)) as T @@ -137,3 +137,55 @@ test.each(['userIdentityKey', 'fromStorageIdentityKey', 'toStorageIdentityKey'] expect(client.request.mock.calls.at(-1)?.[0]).toBe('releaseSyncTransfer') } ) + +test('omitted inline capability retains the six MiB default without staging a small encoded page', async () => { + const client = new TransferClient() + Reflect.set(client, 'settings', { + syncTransfer: { version: 1, maxBytes: capabilities.maxBytes, partBytes: capabilities.partBytes } + }) + const result = { done: true, inserts: 1, updates: 0 } + client.request.mockImplementation(async method => { + expect(method).toBe('processSyncChunk') + return result + }) + await expect(client.processSyncChunk(args(), chunk())).resolves.toEqual(result) + expect(client.request).toHaveBeenCalledTimes(1) +}) + +test.each([ + { requestBinary: true, serverBinary: true }, + { requestBinary: false, serverBinary: true }, + { requestBinary: true, serverBinary: false }, + { requestBinary: false, serverBinary: false } +])('wire bytes require both binary negotiation flags %#', async ({ requestBinary, serverBinary }) => { + const client = new TransferClient(requestBinary, serverBinary), + page = chunk() + page.transactions![0].inputBEEF = Array(128).fill(173) + Reflect.set(client, 'settings', { syncTransfer: undefined }) + client.request.mockResolvedValue({ done: true, inserts: 1, updates: 0 }) + await client.processSyncChunk(args(), page) + const [method, params] = client.request.mock.calls[0], + wire = params[1] as SyncChunk + expect(method).toBe('processSyncChunk') + const binary = wire.transactions![0].inputBEEF + expect(Array.isArray(binary)).toBe(!(requestBinary && serverBinary)) + expect(Array.from(binary!)).toEqual(page.transactions![0].inputBEEF) +}) + +test('binary negotiation applies the one-byte expansion rather than legacy numeric-array overhead', async () => { + const client = new TransferClient(), + page = chunk() + const expectedPage = { + ...page, + transactions: [{ ...page.transactions![0], inputBEEF: Uint8Array.from(page.transactions![0].inputBEEF!) }] + } + const length = encodeSyncTransfer({ args: args(), chunk: expectedPage }).length + Reflect.set(client, 'settings', { syncTransfer: { ...capabilities, inlineBytes: 2 * length } }) + const result = { done: true, inserts: 1, updates: 0 } + client.request.mockImplementation(async method => { + expect(method).toBe('processSyncChunk') + return result + }) + await expect(client.processSyncChunk(args(), page)).resolves.toEqual(result) + expect(client.request).toHaveBeenCalledTimes(1) +}) diff --git a/scripts/dependency-release-governance.test.mjs b/scripts/dependency-release-governance.test.mjs index 7aeb251b7..06fb9c540 100644 --- a/scripts/dependency-release-governance.test.mjs +++ b/scripts/dependency-release-governance.test.mjs @@ -22,7 +22,15 @@ test('dependency and release governance is internally complete', () => { assert.deepEqual(validateDependencyReleaseGovernance(), []) const overrides = collectOverrides() - assert.equal(overrides.length, 27) + assert.equal(overrides.length, 30) + assert.deepEqual( + overrides.filter(entry => entry.selector === 'nodemon'), + ['uhrp-server-basic', 'uhrp-server-cloud-bucket', 'wab'].map(component => ({ + source: `infra/${component}/package.json`, + selector: 'nodemon', + value: { chokidar: '4.0.3' } + })) + ) assert.equal(overrides.filter(entry => entry.selector === 'gaxios').length, 8) assert.equal(overrides.filter(entry => entry.selector === 'uuid').length, 3) assert.equal(overrides.filter(entry => entry.selector === 'brace-expansion').length, 4) diff --git a/sonar-project.properties b/sonar-project.properties index e51a435da..f7dfe98ff 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -73,7 +73,11 @@ infra/wab/src/telemetry.ts,\ infra/uhrp-server-basic/src/telemetry.ts,\ infra/uhrp-server-cloud-bucket/src/telemetry.ts,\ infra/wallet-infra/src/telemetry.ts,\ -infra/message-box-server/src/telemetry.ts +infra/message-box-server/src/telemetry.ts,\ +infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js,\ +infra/uhrp-server-cloud-bucket/dev/check-watch.cjs,\ +infra/wab/dev/bin/nodemon.js,\ +infra/wab/dev/check-watch.cjs # Keep CI and Automatic Analysis aligned on the same narrowly registered # compatibility exceptions. sonar.issue.ignore.multicriteria=werrProtocolNames,curveSingletonAlias,curveSingletonReturn,scriptOpcodeDispatch From 18d94fd0a3cb8718581ec72542185455c8df5666 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 22:25:19 -0700 Subject: [PATCH 101/127] test(wallet): drain HTTP fixture resources after close failures --- docs/guides/wallet-sync-reliability.md | 7 ++ .../SnapshotArchiveHttpFixtureCleanup.test.ts | 93 +++++++++++++++++++ .../test/utils/snapshotArchiveHttpFixtures.ts | 79 ++++++++++++++-- 3 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttpFixtureCleanup.test.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index b829a85bd..0e639a800 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -979,6 +979,13 @@ failure identity and released timers/listeners. Same-server MySQL evidence cover the source-side fence and delayed pool destruction. These tests do not establish PXC or a global physical-pool bound. +The HTTP test fixture independently drains each native archive RPC and listening +socket before destroying the primary database pool. It preserves the tested +public close outcome, including rejection with `undefined`, and continues +cleanup after a synchronous close failure. Four native opening-capture cases +verify that both listeners withdraw, capture destruction settles before the +primary connection closes, and the provider releases its source admission. + ## Backend-bound owner recovery (unadvertised implementation) Apply `2026-10-01-002 add snapshot archive source guards` through the normal diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttpFixtureCleanup.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttpFixtureCleanup.test.ts new file mode 100644 index 000000000..cb934e421 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveHttpFixtureCleanup.test.ts @@ -0,0 +1,93 @@ +import type { WalletInterface } from '@bsv/sdk' +import { snapshotHttpFixture, gate } from '../../../../test/utils/snapshotArchiveHttpFixtures' +import { StorageClient } from '../../remoting/StorageClient' +import { snapshotArchiveRequestId } from './SnapshotArchiveRequest' + +afterEach(() => jest.restoreAllMocks()) + +test.each(['sync-error', 'reject-error', 'reject-undefined', 'resolve-too-soon'] as const)( + 'fixture owns native resources and drains an opening capture when tested close is %s', + async variant => { + const fixture = await snapshotHttpFixture() + const allowOpen = gate(), + entered = gate(), + failure = new Error('synthetic tested close failure') + let pending: Promise<{ failed: boolean; reason?: unknown }> | undefined + let restored: (() => void) | undefined + let physicallyClosed = false, + sourceClosureFailed = false + try { + const { server, url } = await fixture.serve(), + second = await fixture.serve() + const native = (await fixture.storage.knex.client.acquireConnection()) as { open: boolean } + await fixture.storage.knex.client.releaseConnection(native) + const client = new StorageClient(fixture.wallet as unknown as WalletInterface, url) + const transport = (await client.getSnapshotArchiveTransport(fixture.identityKey))! + const offer = await transport.offer() + const fields = { + version: 1 as const, + nonce: 'e'.repeat(64), + notAfter: offer.serverTime + 300000, + maxBytes: 32768 + } + const original = fixture.storage.openSnapshotArchiveSource.bind(fixture.storage) + jest + .spyOn(fixture.storage, 'openSnapshotArchiveSource') + .mockImplementation(async (key, options, owner) => { + const source = await original(key, { ...options, signal: undefined }, owner) + if (source === undefined) throw new Error('Native archive source was unavailable') + void source.closed.then( + () => { + physicallyClosed = true + }, + () => { + sourceClosureFailed = true + } + ) + entered.resolve() + await allowOpen.promise + return source + }) + await transport.start({ ...fields, requestId: snapshotArchiveRequestId(fields) }) + await entered.promise + const tested = jest.spyOn(server, 'close').mockImplementation(() => { + if (variant === 'sync-error') throw failure + if (variant === 'reject-error') return Promise.reject(failure) + if (variant === 'reject-undefined') return Promise.reject(undefined) + return Promise.resolve() + }) + restored = () => tested.mockRestore() + let settled = false + pending = fixture.close().then( + () => { + settled = true + return { failed: false } + }, + reason => { + settled = true + return { failed: true, reason } + } + ) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + expect(physicallyClosed).toBe(false) + expect(native.open).toBe(true) + expect(server.server.listening).toBe(false) + expect(second.server.server.listening).toBe(false) + allowOpen.resolve() + const result = await pending + expect(result.failed).toBe(variant !== 'resolve-too-soon') + if (variant === 'reject-undefined') expect(result.reason).toBeUndefined() + else if (variant !== 'resolve-too-soon') expect(result.reason).toBe(failure) + expect(sourceClosureFailed).toBe(false) + expect(physicallyClosed).toBe(true) + expect(native.open).toBe(false) + expect(Reflect.get(fixture.storage, 'snapshotSyncSource')).toBeUndefined() + } finally { + allowOpen.resolve() + restored?.() + await pending + await fixture.close() + } + } +) diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts index 3b4f69b43..f38f52f06 100644 --- a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts @@ -1,5 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { once } from 'node:events' +import type { Server } from 'node:http' +import type { KnexSnapshotArchiveRpc } from '../../src/storage/snapshot/archive/KnexSnapshotArchiveRpc' import { tmpdir } from 'node:os' import { join } from 'node:path' import { knex } from 'knex' @@ -7,8 +9,12 @@ import { PrivateKey, ProtoWallet } from '@bsv/sdk' import type { Wallet } from '../../src/Wallet' import { StorageKnex } from '../../src/storage/StorageKnex' import { StorageProvider } from '../../src/storage/StorageProvider' -import { StorageServer, type WalletStorageServerOptions } from '../../src/storage/remoting/StorageServer' +import { + StorageServer, + type WalletStorageServerOptions +} from '../../src/storage/remoting/StorageServer' import { seedArchiveClosure } from './snapshotArchiveFixtures' +import { runInSeries } from '../../src/utility/runInSeries' export function gate() { let resolve!: () => void @@ -18,13 +24,30 @@ export function gate() { return { promise, resolve } } +async function closeFixtureArchiveRpc(server: StorageServer): Promise { + const rpc = Reflect.get(server, 'snapshotArchives') as KnexSnapshotArchiveRpc | undefined + await rpc?.close() +} +async function closeFixtureListener(server: StorageServer): Promise { + const listener = server.server as Server | undefined + if (!listener?.listening) return + await new Promise((resolve, reject) => { + listener.close((error?: NodeJS.ErrnoException) => { + if (error !== undefined && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error) + else resolve() + }) + }) +} + export async function snapshotHttpFixture(snapshotSync = true, seedClosure = true) { const directory = await mkdtemp(join(tmpdir(), 'snapshot-http-')) const key = PrivateKey.fromRandom() const identityKey = key.toPublicKey().toString() const wallet = new ProtoWallet(key) const serverKey = PrivateKey.fromRandom() - const serverWallet = Object.assign(new ProtoWallet(serverKey), { chain: 'test' }) as unknown as Wallet + const serverWallet = Object.assign(new ProtoWallet(serverKey), { + chain: 'test' + }) as unknown as Wallet const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), snapshotSync, @@ -37,16 +60,42 @@ export async function snapshotHttpFixture(snapshotSync = true, seedClosure = tru }) const servers: StorageServer[] = [] const close = async () => { - await Promise.all(servers.map(server => server.close())) - await storage.destroy() - await rm(directory, { recursive: true, force: true }) + // Test the public close outcome, but own the actual resources independently. + // A failed or mutated close must not leak a listener/capture into later tests. + const draining = servers.flatMap(server => [ + (async () => await server.close())(), + closeFixtureArchiveRpc(server), + closeFixtureListener(server) + ]) + const results = await Promise.allSettled(draining) + const failures = results + .filter(result => result.status === 'rejected') + .map(result => result.reason) + await runInSeries( + [ + async () => await storage.destroy(), + async () => await storage.knex.destroy(), + async () => await rm(directory, { recursive: true, force: true }) + ], + async cleanup => { + try { + await cleanup() + } catch (error) { + failures.push(error) + } + } + ) + if (failures.length === 1) throw failures[0] + if (failures.length) throw new AggregateError(failures, 'Snapshot HTTP fixture cleanup failed') } try { await storage.knex.raw('PRAGMA journal_mode = WAL') await storage.migrate('HTTP snapshot source', 'http-snapshot-source') await storage.makeAvailable() const { user } = await storage.findOrInsertUser(identityKey) - const { user: other } = await storage.findOrInsertUser(PrivateKey.fromRandom().toPublicKey().toString()) + const { user: other } = await storage.findOrInsertUser( + PrivateKey.fromRandom().toPublicKey().toString() + ) if (seedClosure) await seedArchiveClosure(storage, user.userId, other.userId) const serve = async (options: Partial = {}) => { const server = new StorageServer(storage, { @@ -61,12 +110,24 @@ export async function snapshotHttpFixture(snapshotSync = true, seedClosure = tru server.start() if (!server.server.listening) await once(server.server, 'listening') const address = server.server.address() - if (address === null || typeof address === 'string') throw new Error('Fixture listener did not bind') + if (address === null || typeof address === 'string') + throw new Error('Fixture listener did not bind') return { server, url: `http://127.0.0.1:${address.port}` } } - return { storage, identityKey, wallet, serverIdentityKey: serverKey.toPublicKey().toString(), serve, close } + return { + storage, + identityKey, + wallet, + serverIdentityKey: serverKey.toPublicKey().toString(), + serve, + close + } } catch (error) { - await close() + try { + await close() + } catch (cleanup) { + throw new AggregateError([error, cleanup], 'Snapshot HTTP fixture setup and cleanup failed') + } throw error } } From be295a2a9c6f23502bbe26bb1cf8741e89756f97 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 2 Oct 2026 23:34:43 -0700 Subject: [PATCH 102/127] fix(dev): preserve watcher polling and resolve analyzer findings --- docs/reference/dependency-policy.md | 4 ++++ infra/uhrp-server-basic/dev/bin/nodemon.js | 4 ++-- infra/uhrp-server-basic/dev/check-watch.cjs | 17 ++++++++--------- .../uhrp-server-cloud-bucket/dev/bin/nodemon.js | 4 ++-- .../dev/check-watch.cjs | 17 ++++++++--------- infra/wab/dev/bin/nodemon.js | 4 ++-- infra/wab/dev/check-watch.cjs | 17 ++++++++--------- .../test/utils/snapshotArchiveHttpFixtures.ts | 4 ++-- 8 files changed, 36 insertions(+), 35 deletions(-) diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index 765cc0b2a..0b1191734 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -152,6 +152,10 @@ TypeScript and env extensions, manual `rs` and graceful shutdown regressions run before each service's ordinary tests. The service-copy policy keeps both adapter and native regression identical across all three service contexts. +The adapter normalizes every backslash and repeated slash without changing its +ignore matching. Native watcher polling permits 400 predicate attempts, with a +25 ms wait after every failed attempt, and preserves early process-exit errors. + The three new registered substitutions bring the combined retained count to 30. Remove them and the adapter together after a compatible official Nodemon release resolves the dependency path natively and all watcher, frozen audit, service and protected Linux image gates pass. These standalone development diff --git a/infra/uhrp-server-basic/dev/bin/nodemon.js b/infra/uhrp-server-basic/dev/bin/nodemon.js index 0ceb8fa27..fe89254b7 100644 --- a/infra/uhrp-server-basic/dev/bin/nodemon.js +++ b/infra/uhrp-server-basic/dev/bin/nodemon.js @@ -7,8 +7,8 @@ const { bus } = require('nodemon/lib/utils') const { rulesToMonitor } = require('nodemon/lib/monitor/match') function unix(value) { - const slashes = value.replace(/\\/g, '/') - return (slashes.startsWith('//') ? '/' : '') + slashes.replace(/\/{2,}/g, '/') + const slashes = value.replaceAll('\\', '/') + return (slashes.startsWith('//') ? '/' : '') + slashes.replaceAll(/\/{2,}/g, '/') } function normalizeIgnored(value, cwd) { if (typeof value !== 'string') return value diff --git a/infra/uhrp-server-basic/dev/check-watch.cjs b/infra/uhrp-server-basic/dev/check-watch.cjs index 1d5095588..7e380ce0e 100644 --- a/infra/uhrp-server-basic/dev/check-watch.cjs +++ b/infra/uhrp-server-basic/dev/check-watch.cjs @@ -75,14 +75,13 @@ test( errors = '', forced = false const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] - async function waitFor(predicate, description) { - for (let i = 0; i < 400; i++) { - if (predicate()) return - if (child.exitCode !== null || child.signalCode !== null) - throw new Error(`Watcher exited during ${description}: ${errors}`) - await delay(25) - } - throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + async function waitFor(predicate, description, remaining = 400) { + if (remaining === 0) throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + if (predicate()) return + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Watcher exited during ${description}: ${errors}`) + await delay(25) + return waitFor(predicate, description, remaining - 1) } try { await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) @@ -177,7 +176,7 @@ test( } assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) } finally { - if (child && child.exitCode === null && child.signalCode === null) { + if (child?.exitCode === null && child.signalCode === null) { process.kill(-child.pid, 'SIGTERM') const escalation = setTimeout(() => { forced = true diff --git a/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js b/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js index 0ceb8fa27..fe89254b7 100644 --- a/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js +++ b/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js @@ -7,8 +7,8 @@ const { bus } = require('nodemon/lib/utils') const { rulesToMonitor } = require('nodemon/lib/monitor/match') function unix(value) { - const slashes = value.replace(/\\/g, '/') - return (slashes.startsWith('//') ? '/' : '') + slashes.replace(/\/{2,}/g, '/') + const slashes = value.replaceAll('\\', '/') + return (slashes.startsWith('//') ? '/' : '') + slashes.replaceAll(/\/{2,}/g, '/') } function normalizeIgnored(value, cwd) { if (typeof value !== 'string') return value diff --git a/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs b/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs index 1d5095588..7e380ce0e 100644 --- a/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs +++ b/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs @@ -75,14 +75,13 @@ test( errors = '', forced = false const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] - async function waitFor(predicate, description) { - for (let i = 0; i < 400; i++) { - if (predicate()) return - if (child.exitCode !== null || child.signalCode !== null) - throw new Error(`Watcher exited during ${description}: ${errors}`) - await delay(25) - } - throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + async function waitFor(predicate, description, remaining = 400) { + if (remaining === 0) throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + if (predicate()) return + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Watcher exited during ${description}: ${errors}`) + await delay(25) + return waitFor(predicate, description, remaining - 1) } try { await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) @@ -177,7 +176,7 @@ test( } assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) } finally { - if (child && child.exitCode === null && child.signalCode === null) { + if (child?.exitCode === null && child.signalCode === null) { process.kill(-child.pid, 'SIGTERM') const escalation = setTimeout(() => { forced = true diff --git a/infra/wab/dev/bin/nodemon.js b/infra/wab/dev/bin/nodemon.js index 0ceb8fa27..fe89254b7 100644 --- a/infra/wab/dev/bin/nodemon.js +++ b/infra/wab/dev/bin/nodemon.js @@ -7,8 +7,8 @@ const { bus } = require('nodemon/lib/utils') const { rulesToMonitor } = require('nodemon/lib/monitor/match') function unix(value) { - const slashes = value.replace(/\\/g, '/') - return (slashes.startsWith('//') ? '/' : '') + slashes.replace(/\/{2,}/g, '/') + const slashes = value.replaceAll('\\', '/') + return (slashes.startsWith('//') ? '/' : '') + slashes.replaceAll(/\/{2,}/g, '/') } function normalizeIgnored(value, cwd) { if (typeof value !== 'string') return value diff --git a/infra/wab/dev/check-watch.cjs b/infra/wab/dev/check-watch.cjs index 1d5095588..7e380ce0e 100644 --- a/infra/wab/dev/check-watch.cjs +++ b/infra/wab/dev/check-watch.cjs @@ -75,14 +75,13 @@ test( errors = '', forced = false const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] - async function waitFor(predicate, description) { - for (let i = 0; i < 400; i++) { - if (predicate()) return - if (child.exitCode !== null || child.signalCode !== null) - throw new Error(`Watcher exited during ${description}: ${errors}`) - await delay(25) - } - throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + async function waitFor(predicate, description, remaining = 400) { + if (remaining === 0) throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) + if (predicate()) return + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Watcher exited during ${description}: ${errors}`) + await delay(25) + return waitFor(predicate, description, remaining - 1) } try { await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) @@ -177,7 +176,7 @@ test( } assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) } finally { - if (child && child.exitCode === null && child.signalCode === null) { + if (child?.exitCode === null && child.signalCode === null) { process.kill(-child.pid, 'SIGTERM') const escalation = setTimeout(() => { forced = true diff --git a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts index f38f52f06..499d24933 100644 --- a/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts +++ b/packages/wallet/wallet-toolbox/test/utils/snapshotArchiveHttpFixtures.ts @@ -125,8 +125,8 @@ export async function snapshotHttpFixture(snapshotSync = true, seedClosure = tru } catch (error) { try { await close() - } catch (cleanup) { - throw new AggregateError([error, cleanup], 'Snapshot HTTP fixture setup and cleanup failed') + } catch (error_) { + throw new AggregateError([error, error_], 'Snapshot HTTP fixture setup and cleanup failed') } throw error } From c9ab572ee1b6ff28a6535555e2767f88d23de0bc Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 3 Oct 2026 12:18:59 -0700 Subject: [PATCH 103/127] fix(wallet): recover queued primary selection after partial failure --- .github/workflows/ci.yml | 5 + docs/guides/wallet-sync-reliability.md | 11 ++ docs/reference/package-api-migrations.md | 82 ++++----- governance/mutation-testing/targets.mjs | 14 ++ governance/package-release-notes.json | 12 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 9 + .../src/storage/WalletStorageManager.ts | 162 ++++++++++-------- .../snapshot/SnapshotSync.integration.test.ts | 8 +- .../snapshot/SnapshotSync.property.test.ts | 96 +++++++++++ .../snapshot/SnapshotSyncSession.test.ts | 27 ++- .../src/storage/sync/syncSession.test.ts | 155 ++++++++++++++++- scripts/ci-orchestration.test.mjs | 9 +- scripts/mutation-partitions.mjs | 15 ++ scripts/mutation-partitions.test.mjs | 58 +++++++ scripts/mutation-testing.test.mjs | 29 ++++ specs/wallet/sync-portability-program.md | 12 ++ 16 files changed, 573 insertions(+), 131 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78cd9102e..2eb2fb0b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -604,6 +604,11 @@ jobs: with: pattern: mutation-wallet-retained-snapshot-* path: .mutation-parts/wallet-retained-snapshot + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-snapshot-sync') + with: + pattern: mutation-wallet-snapshot-sync-* + path: .mutation-parts/wallet-snapshot-sync - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 if: contains(fromJSON(needs.prepare.outputs.partition-targets || '[]'), 'wallet-snapshot-journal') with: diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 0e639a800..28c527560 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -520,6 +520,17 @@ timestamp rule; they do not replace it with a manager's older cached selection. Pull retains the destination manager's selection. Serialized fallback preserves the same directional rules. This compatibility behavior does not refresh the manager cache or implement primary reconciliation. +A queued `setActive` request checks the selected provider after earlier switches +finish. For example, a request to reselect A while a switch from A to B is pending +waits for that switch, then selects A. The same ordering holds when the earlier +switch fails. A progress formatter that throws before a transition starts leaves +existing sync sessions valid. If a transition fails after changing a store, the +manager discards cached primary authorization and reloads persisted selections +before subsequent access. Conflicting selections refuse active authorization +until a later successful selection reconciles the stores. A failed recovery +reload releases ownership so later requests can retry. Primary reconciliation +still uses the serialized copy loop; bounded reconciliation remains part of the +implementation program. A prepared page is single-use and stale checkpoints reject. If an acknowledgement is lost while the same source view remains alive, read the destination checkpoint and resume it. When the source view is lost, open a new view and restart traversal diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 3119c05c2..1dde3b490 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -528,8 +528,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. -- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. +- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. +- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -540,8 +540,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. -- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. +- Release note: Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. +- Migration: The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 5688b0979..cabe1d468 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -164,6 +164,20 @@ function snapshotSyncMutationTargets(repositoryRoot) { 'src/storage/WalletStorageManager.ts', 'async updateBackups(', 'async setActive(' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'async setActive(', + 'getStoreEndpointURL(' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/WalletStorageManager.ts', + 'private async withAccess(', + 'runAsWriter(' ) ], ...jestTarget( diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 7c400dfd5..01d209260 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,22 +210,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced." + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns.", - "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." + "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox resumable sync, fair storage scheduling, canonical proof recovery and IndexedDB paging improvements into the browser entry point. Adds native Chromium large-copy/foreground-latency acceptance alongside the packed browser contract. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry.", + "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor.", - "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement." + "summary": "Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Carries the compatible Toolbox sync contracts, fair scheduling and canonical proof recovery into the mobile entry point without adding Node or IndexedDB dependencies. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Exposes retained-view option/result types and the unsupported base-provider capability without adding a retained IndexedDB or remote implementation. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry.", + "migration": "The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation." }, { "name": "create-bsv-app", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index d783c6f4e..9cfdfe6a3 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,15 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Preserve queued primary reselection: a request to select A during a pending + switch from A to B waits for that switch and then selects A, including after + an earlier failure. Check no-op selections under the existing ownership + boundary and preserve valid sync sessions when a progress formatter throws + before the transition. After partial propagation failure, reload persisted + selections before authorizing or executing queued access. Conflicting stores + refuse active authorization; failed reloads release ownership for retry. No + API, wire or schema migration is required. + - Add internal monotonic continuity-floor transactions and bounded primary-key tombstone collection. Current receipt locks pin all live prefixes; collection preserves live/newer records and all thirteen source tables. WAL/RC/RR fixtures diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index 21b229ccd..67935273e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -342,6 +342,16 @@ export class WalletStorageManager implements sdk.WalletStorage { concurrent ? 'read' : 'exclusive', background ? 'background' : 'foreground' ) + // A preceding transition can fail after updating a managed store. Reload + // its persisted selection under ownership before using the cached provider. + if (!this._isAvailable) { + try { + await this.makeAvailable() + } catch (error) { + release() + throw error + } + } // Primary selection may have changed while this request was queued. Never // apply the former provider's read-sharing promise to its replacement. if (concurrent && this._active?.access?.concurrentReads !== true) { @@ -893,7 +903,10 @@ export class WalletStorageManager implements sdk.WalletStorage { snapshotCheckpoint: progress.snapshotCheckpoint === undefined ? undefined - : { ...progress.snapshotCheckpoint, cursor: copySnapshotCursor(progress.snapshotCheckpoint.cursor) } + : { + ...progress.snapshotCheckpoint, + cursor: copySnapshotCursor(progress.snapshotCheckpoint.cursor) + } } options.onProgress?.(progress) } @@ -1258,85 +1271,92 @@ export class WalletStorageManager implements sdk.WalletStorage { let log = progLog(`setActive to ${(newActive.settings as TableSettings).storageName}`) - if (storageIdentityKey === this.getActiveStore() && this.isActiveEnabled) { - /** Setting the current active as the new active is a permitted no-op. */ - return log + progLog(' unchanged\n') - } - - log += progLog('\n') - log += await this.runAsSync(async _sync => { + // An earlier queued switch may have changed the primary since this call. + // Decide even a no-op under the same ownership as an actual switch. + if (storageIdentityKey === this.getActiveStore() && this.isActiveEnabled) { + return progLog(' unchanged\n') + } + let log = progLog('\n') this.generation++ - let log = '' - - if ((this._conflictingActives as ManagedStorage[]).length > 0) { - // Merge state from conflicting actives into `newActive`. - - // Handle case where new active is current active to resolve conflicts. - // And where new active is one of the current conflict actives. - ;(this._conflictingActives as ManagedStorage[]).push(this._active as ManagedStorage) - // Remove the new active from conflicting actives and - // set new active as the conflicting active that matches the target `storageIdentityKey` - this._conflictingActives = (this._conflictingActives as ManagedStorage[]).filter(ca => { - const isNewActive = (ca.settings as TableSettings).storageIdentityKey === storageIdentityKey - return !isNewActive - }) - // Merge state from conflicting actives into `newActive`. - for (const conflict of this._conflictingActives) { - log += progLog('MERGING STATE FROM CONFLICTING ACTIVES:\n') - const sfr = await this.syncToWriter( - { identityKey, userId: (newActive.user as TableUser).userId, isActive: false }, - newActive.storage, - conflict.storage, - undefined, - progLog - ) - log += sfr.log + try { + if ((this._conflictingActives as ManagedStorage[]).length > 0) { + // Merge state from conflicting actives into `newActive`. + + // Handle case where new active is current active to resolve conflicts. + // And where new active is one of the current conflict actives. + ;(this._conflictingActives as ManagedStorage[]).push(this._active as ManagedStorage) + // Remove the new active from conflicting actives and + // set new active as the conflicting active that matches the target `storageIdentityKey` + this._conflictingActives = (this._conflictingActives as ManagedStorage[]).filter(ca => { + const isNewActive = (ca.settings as TableSettings).storageIdentityKey === storageIdentityKey + return !isNewActive + }) + + // Merge state from conflicting actives into `newActive`. + for (const conflict of this._conflictingActives) { + log += progLog('MERGING STATE FROM CONFLICTING ACTIVES:\n') + const sfr = await this.syncToWriter( + { identityKey, userId: (newActive.user as TableUser).userId, isActive: false }, + newActive.storage, + conflict.storage, + undefined, + progLog + ) + log += sfr.log + } + log += progLog('PROPAGATE MERGED ACTIVE STATE TO NON-ACTIVES\n') + } else { + log += progLog('BACKUP CURRENT ACTIVE STATE THEN SET NEW ACTIVE\n') } - log += progLog('PROPAGATE MERGED ACTIVE STATE TO NON-ACTIVES\n') - } else { - log += progLog('BACKUP CURRENT ACTIVE STATE THEN SET NEW ACTIVE\n') - } - // If there were conflicting actives, - // Push state merged from all merged actives into newActive to all stores other than the now single active. - // Otherwise, - // Push state from current active to all other stores. - const backupSource = - (this._conflictingActives as ManagedStorage[]).length > 0 ? newActive : (this._active as ManagedStorage) - - // Update the backupSource's user record with the new activeStorage - // which will propagate to all other stores in the following backup loop. - await backupSource.storage.setActive( - { identityKey, userId: (backupSource.user as TableUser).userId }, - storageIdentityKey - ) + // If there were conflicting actives, + // Push state merged from all merged actives into newActive to all stores other than the now single active. + // Otherwise, + // Push state from current active to all other stores. + const backupSource = + (this._conflictingActives as ManagedStorage[]).length > 0 ? newActive : (this._active as ManagedStorage) + + // Update the backupSource's user record with the new activeStorage + // which will propagate to all other stores in the following backup loop. + await backupSource.storage.setActive( + { identityKey, userId: (backupSource.user as TableUser).userId }, + storageIdentityKey + ) - for (const store of this._stores) { - // Update cached user.activeStorage of all stores - ;(store.user as TableUser).activeStorage = storageIdentityKey - - if ( - (store.settings as TableSettings).storageIdentityKey !== - (backupSource.settings as TableSettings).storageIdentityKey - ) { - // If this store is not the backupSource store push state from backupSource to this store. - const stwr = await this.syncToWriter( - { identityKey, userId: (store.user as TableUser).userId, isActive: false }, - store.storage, - backupSource.storage, - undefined, - progLog - ) - log += stwr.log + for (const store of this._stores) { + // Update cached user.activeStorage of all stores + ;(store.user as TableUser).activeStorage = storageIdentityKey + + if ( + (store.settings as TableSettings).storageIdentityKey !== + (backupSource.settings as TableSettings).storageIdentityKey + ) { + // If this store is not the backupSource store push state from backupSource to this store. + const stwr = await this.syncToWriter( + { identityKey, userId: (store.user as TableUser).userId, isActive: false }, + store.storage, + backupSource.storage, + undefined, + progLog + ) + log += stwr.log + } } - } - this._isAvailable = false - await this.makeAvailable() + this._isAvailable = false + await this.makeAvailable() - return log + return log + } catch (error) { + // Partial propagation can leave stores disagreeing about the primary. + // Discard cached users before the next authorization or queued access. + this._isAvailable = false + this._authId.isActive = false + for (const store of this._stores) store.isAvailable = false + throw error + } }) return log diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts index bec51a428..7ee4b76ce 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.integration.test.ts @@ -1185,15 +1185,15 @@ test('archive positions persist with rows, detach before proof I/O and reject an const first = await view.readPage('txLabels', undefined, { maxRows: 1 }) const second = await view.readPage('txLabels', first.cursor, { maxRows: 1 }) const last = await view.readPage('txLabels', second.cursor, { maxRows: 1 }) - first.cursor!.archivePosition = { version: 1, archiveId: 'c'.repeat(64), sequence: 3, rowOffset: 1 } + Reflect.set(first.cursor!, 'archivePosition', { version: 1, archiveId: 'c'.repeat(64), sequence: 3, rowOffset: 1 }) checkpoint = (await (await writer.prepare(checkpoint, first))()).checkpoint expect((await writer.checkpoint(identity, 'source'))!.cursor!.archivePosition).toEqual( first.cursor!.archivePosition ) - second.cursor!.archivePosition = { ...first.cursor!.archivePosition, rowOffset: 2 } + Reflect.set(second.cursor!, 'archivePosition', { ...first.cursor!.archivePosition, rowOffset: 2 }) const preparing = writer.prepare(checkpoint, second) - checkpoint.cursor!.archivePosition!.rowOffset = 999 - second.cursor!.archivePosition.rowOffset = 999 + Reflect.set(checkpoint.cursor!.archivePosition!, 'rowOffset', 999) + Reflect.set(second.cursor!.archivePosition!, 'rowOffset', 999) checkpoint = (await (await preparing)()).checkpoint expect(checkpoint.cursor!.archivePosition!.rowOffset).toBe(2) expect(await destination.findTxLabels({ partial: {} })).toHaveLength(2) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts index 0ef4009f8..2bc340075 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts @@ -6,6 +6,8 @@ import { knex } from 'knex' import { StorageKnex } from '../StorageKnex' import { StorageProvider } from '../StorageProvider' import { snapshotSyncTables } from './SnapshotSync' +import { WalletStorageManager } from '../WalletStorageManager' +import type { WalletStorageProvider } from '../../sdk/WalletStorage.interfaces' const MIN_PROPERTY_RUNS = 300 const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) @@ -146,3 +148,97 @@ test('random durable page schedules recover acknowledgements and restarts withou await rm(directory, { recursive: true, force: true }) } }, 120000) + +function deferred() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +test('primary requests retain queue order through a delayed success or failure', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.constantFrom('A', 'B', 'C'), { minLength: 1, maxLength: 12 }), + fc.boolean(), + async (requests, failFirst) => { + const entered = deferred(), + release = deferred(), + failure = new Error('synthetic first primary switch failure') + let first = true + function provider(key: string, userId: number) { + const settings = { + chain: 'test', + storageIdentityKey: key, + storageName: key, + dbtype: 'SQLite', + maxOutputScriptLength: 1024 + } + const user = { userId, identityKey: 'synthetic-primary-order', activeStorage: 'A' } + return { + user, + isStorageProvider: () => false, + makeAvailable: async () => settings, + getSettings: () => settings, + findOrInsertUser: async () => ({ user: { ...user } }), + setActive: async (_auth: unknown, activeStorage: string) => { + if (first) { + first = false + entered.resolve() + await release.promise + if (failFirst) throw failure + } + user.activeStorage = activeStorage + } + } + } + const providers = ['A', 'B', 'C'].map((key, index) => provider(key, index + 1)) + const manager = new WalletStorageManager( + 'synthetic-primary-order', + providers[0] as unknown as WalletStorageProvider, + providers.slice(1) as unknown as WalletStorageProvider[] + ) + await manager.makeAvailable() + manager.syncToWriter = async (_auth, writer, source) => { + const target = providers.find( + p => p.getSettings().storageIdentityKey === writer.getSettings().storageIdentityKey + )! + const origin = providers.find( + p => p.getSettings().storageIdentityKey === source!.getSettings().storageIdentityKey + )! + target.user.activeStorage = origin.user.activeStorage + return { inserts: 0, updates: 0, log: '' } + } + const switching = manager.setActive('B') + const checkedSwitch = failFirst ? expect(switching).rejects.toBe(failure) : switching + await entered.promise + let completed = 0 + const queued = requests.map(key => + manager.setActive(key).then(() => { + completed++ + }) + ) + try { + await new Promise(resolve => setImmediate(resolve)) + expect(completed).toBe(0) + } finally { + release.resolve() + await checkedSwitch + await Promise.all(queued) + } + const expected = requests[requests.length - 1] + expect(manager.getActiveStore()).toBe(expected) + expect(manager.isActiveEnabled).toBe(true) + expect(providers.map(p => p.user.activeStorage)).toEqual([expected, expected, expected]) + } + ), + { + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + seed: Number.isSafeInteger(requestedSeed) ? requestedSeed : 3242026, + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts index 829ba4b03..fa173d894 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts @@ -33,8 +33,16 @@ function session() { begin: jest.fn(async () => checkpoint), prepare: jest.fn(async () => apply) } as unknown as SnapshotSyncStorage - const commit = jest.fn(async (work: () => Promise) => await work()) - return { input: { view, destination, commit, activeStorage: 'source' }, checkpoint, view, destination, apply, commit } + const ownership = { commit: async (work: () => Promise): Promise => await work() } + const commit = jest.spyOn(ownership, 'commit') + return { + input: { view, destination, commit: ownership.commit, activeStorage: 'source' }, + checkpoint, + view, + destination, + apply, + commit + } } test.each([ @@ -56,16 +64,21 @@ test.each([ test('progress uses detached checkpoints and reports the committed counts and timings', async () => { const f = session() - f.checkpoint.cursor!.archivePosition = { version: 1, archiveId: 'c'.repeat(64), sequence: 12, rowOffset: 7 } + Reflect.set(f.checkpoint.cursor!, 'archivePosition', { + version: 1, + archiveId: 'c'.repeat(64), + sequence: 12, + rowOffset: 7 + }) const states: SyncSessionProgress['state'][] = [] const result = await runSnapshotSyncSession(f.input, { maxItems: 17, maxRoughSize: 2048, onProgress: progress => { states.push(progress.state) - if (progress.snapshotCheckpoint?.cursor) progress.snapshotCheckpoint.cursor.after[0] = 999 + if (progress.snapshotCheckpoint?.cursor) Reflect.set(progress.snapshotCheckpoint.cursor.after, '0', 999) if (progress.snapshotCheckpoint?.cursor?.archivePosition) - progress.snapshotCheckpoint.cursor.archivePosition.rowOffset = 999 + Reflect.set(progress.snapshotCheckpoint.cursor.archivePosition, 'rowOffset', 999) if (progress.snapshotCheckpoint) progress.snapshotCheckpoint.identityKey = 'changed-by-listener' if (progress.state === 'committed') { expect(progress).toMatchObject({ pages: 1, inserts: 2, updates: 3 }) @@ -217,8 +230,8 @@ test('a nonterminal acknowledgement advances a detached cursor before finishing const result = await runSnapshotSyncSession(f.input, { onProgress: progress => { if (progress.state === 'committed' && progress.pages === 1) { - acknowledged.cursor!.after[0] = 999 - acknowledged.cursor!.archivePosition!.rowOffset = 999 + Reflect.set(acknowledged.cursor!.after, '0', 999) + Reflect.set(acknowledged.cursor!.archivePosition!, 'rowOffset', 999) acknowledged.sequence = 999 } } diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts index e786fdec1..64d276df6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/syncSession.test.ts @@ -324,7 +324,7 @@ test('progress observers cannot corrupt checkpoint state and unfinished zero-pro expect(await writer.countTxLabels({ partial: {} })).toBe(25) const process = jest .spyOn(writer, 'prepareSyncChunk') - .mockResolvedValue(async () => ({ done: false, inserts: 0, updates: 0 })) + .mockResolvedValue(async () => ({ done: false, inserts: 0, updates: 0, maxUpdated_at: undefined })) await expect(manager.syncFromReaderResumable(identityKey, reader)).rejects.toThrow('without advancing') expect(process).toHaveBeenCalledTimes(1) }) @@ -451,3 +451,156 @@ test.each([undefined, 10000000])( expect(calls.mock.calls.every(([args]) => args.maxRoughSize === (maximum ?? 262144))).toBe(true) } ) + +test.each([false, true])( + 'queued primary reselection waits for the earlier switch, including failure=%s', + async failEarlier => { + const first = await makeStorage(), + second = await makeStorage() + const manager = new WalletStorageManager(identityKey, first, [second]) + await manager.makeAvailable() + const firstKey = first.getSettings().storageIdentityKey, + secondKey = second.getSettings().storageIdentityKey + await manager.setActive(firstKey) + const entered = deferred(), + release = deferred(), + failure = new Error('synthetic primary switch failure') + const update = first.setActive.bind(first) + jest.spyOn(first, 'setActive').mockImplementationOnce(async (...args) => { + entered.resolve() + await release.promise + if (failEarlier) throw failure + return await update(...args) + }) + const switching = manager.setActive(secondKey) + const checkedSwitch = failEarlier ? expect(switching).rejects.toBe(failure) : switching + await entered.promise + let settled = false + const reselecting = manager.setActive(firstKey).then(log => { + settled = true + return log + }) + try { + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + } finally { + release.resolve() + await checkedSwitch + await reselecting + } + expect(manager.getActiveStore()).toBe(firstKey) + expect(manager.isActiveEnabled).toBe(true) + expect((await first.findUserByIdentityKey(identityKey))!.activeStorage).toBe(firstKey) + expect((await second.findUserByIdentityKey(identityKey))!.activeStorage).toBe(firstKey) + } +) + +test('a failed primary-switch prelude leaves an in-flight sync on the same primary usable', async () => { + const { reader, writer } = await fixture(3), + replacement = await makeStorage() + const manager = new WalletStorageManager(identityKey, writer, [replacement]) + await manager.makeAvailable() + const current = writer.getSettings().storageIdentityKey + await manager.setActive(current) + const entered = deferred(), + release = deferred(), + failure = new Error('synthetic primary progress observer failure') + const read = reader.getSyncChunk.bind(reader) + jest.spyOn(reader, 'getSyncChunk').mockImplementationOnce(async args => { + const chunk = await read(args) + entered.resolve() + await release.promise + return chunk + }) + const copying = manager.syncFromReaderResumable(identityKey, reader, { maxItems: 2 }) + const checkedCopy = expect(copying).resolves.toMatchObject({ status: 'completed', mode: 'paged' }) + await entered.promise + try { + await expect( + manager.setActive(replacement.getSettings().storageIdentityKey, message => { + if (message === '\n') throw failure + return message + }) + ).rejects.toBe(failure) + expect(manager.getActiveStore()).toBe(current) + } finally { + release.resolve() + await checkedCopy + } + expect(await writer.countTxLabels({ partial: {} })).toBe(3) +}) + +test.each(['', 'unregistered'])( + 'an invalid primary %s initializes safely and refuses before provider mutation', + async key => { + const first = await makeStorage() + const manager = new WalletStorageManager(identityKey, first) + const update = jest.spyOn(first, 'setActive') + const format = jest.fn((message: string) => message) + expect(manager.isAvailable()).toBe(false) + await expect(manager.setActive(key, format)).rejects.toMatchObject({ code: 'WERR_INVALID_PARAMETER' }) + expect(manager.getActiveStore()).toBe(first.getSettings().storageIdentityKey) + expect(manager.isActiveEnabled).toBe(true) + expect(update).not.toHaveBeenCalled() + expect(format).not.toHaveBeenCalled() + expect(await manager.setActive(first.getSettings().storageIdentityKey)).toContain(' unchanged\n') + } +) + +test('a partial primary propagation failure reloads persisted selection before authorizing or retrying', async () => { + const first = await makeStorage(), + second = await makeStorage() + const manager = new WalletStorageManager(identityKey, first, [second]) + await manager.makeAvailable() + const firstKey = first.getSettings().storageIdentityKey, + secondKey = second.getSettings().storageIdentityKey + await manager.setActive(firstKey) + const failure = new Error('synthetic partial propagation failure') + jest.spyOn(second, 'processSyncChunk').mockRejectedValueOnce(failure) + await expect(manager.setActive(secondKey)).rejects.toBe(failure) + expect((await first.findUserByIdentityKey(identityKey))!.activeStorage).toBe(secondKey) + expect((await second.findUserByIdentityKey(identityKey))!.activeStorage).toBe(firstKey) + await expect(manager.getAuth(true)).rejects.toMatchObject({ code: 'WERR_NOT_ACTIVE' }) + expect(manager.isActiveEnabled).toBe(false) + await manager.setActive(firstKey) + expect(manager.isActiveEnabled).toBe(true) + expect((await first.findUserByIdentityKey(identityKey))!.activeStorage).toBe(firstKey) + expect((await second.findUserByIdentityKey(identityKey))!.activeStorage).toBe(firstKey) +}) + +test('a queued request releases ownership after a failed primary recovery reload', async () => { + const first = await makeStorage(), + second = await makeStorage() + const manager = new WalletStorageManager(identityKey, first, [second]) + await manager.makeAvailable() + const firstKey = first.getSettings().storageIdentityKey, + secondKey = second.getSettings().storageIdentityKey + await manager.setActive(firstKey) + const entered = deferred(), + release = deferred() + const failure = new Error('synthetic partial propagation failure'), + reloadFailure = new Error('synthetic recovery reload failure') + jest.spyOn(second, 'processSyncChunk').mockImplementationOnce(async () => { + entered.resolve() + await release.promise + throw failure + }) + const switching = expect(manager.setActive(secondKey)).rejects.toBe(failure) + await entered.promise + jest.spyOn(first, 'findOrInsertUser').mockRejectedValueOnce(reloadFailure) + const operation = jest.fn(async () => undefined) + const queued = expect(manager.runAsWriter(operation)).rejects.toBe(reloadFailure) + try { + await new Promise(resolve => setImmediate(resolve)) + expect(operation).not.toHaveBeenCalled() + } finally { + release.resolve() + await switching + await queued + } + expect(operation).not.toHaveBeenCalled() + expect(await manager.runAsReader(async provider => provider.getSettings().storageIdentityKey)).toBe(firstKey) + await expect(manager.getAuth(true)).rejects.toMatchObject({ code: 'WERR_NOT_ACTIVE' }) + await manager.setActive(firstKey) + expect((await manager.getAuth(true)).isActive).toBe(true) +}) diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 84c5dfb86..f25c9218c 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -6,7 +6,7 @@ import test from 'node:test' import { REPOSITORY_ROOT } from './repository-health.mjs' import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' -import { partitionedMutationTargets } from './mutation-partitions.mjs' +import { partitionedMutationTargets, partitionMutationTarget } from './mutation-partitions.mjs' const CI_PATH = join(REPOSITORY_ROOT, '.github/workflows/ci.yml') const MUTATION_PATH = join(REPOSITORY_ROOT, '.github/workflows/mutation-tests.yml') @@ -38,6 +38,13 @@ test('CI downloads every canonical execution partition before verifying its comp const targets = buildMutationTargets(REPOSITORY_ROOT) const partitioned = partitionedMutationTargets(Object.keys(targets), targets) assert.ok(partitioned.length > 0) + assert.ok(partitioned.includes('wallet-snapshot-sync')) + assert.deepEqual( + partitionMutationTarget('wallet-snapshot-sync', targets['wallet-snapshot-sync']).map( + part => part.id + ), + ['session', 'checkpoint', 'copy', 'storage', 'primary'] + ) const workflow = readFileSync(CI_PATH, 'utf8') const gate = workflowJobBlocks(workflow).find(job => job.name === 'mutation-quality').source const verify = gate.indexOf( diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index b20e03f75..e98ac9811 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -12,6 +12,21 @@ const plans = new Map([ ]) } ], + [ + 'wallet-snapshot-sync', + { + fallback: 'session', + files: new Map([ + ['src/utility/runInSeries.ts', 'session'], + ['src/storage/sync/syncSession.ts', 'session'], + ['src/storage/snapshot/SnapshotSync.ts', 'session'], + ['src/storage/sync/syncCheckpoint.ts', 'checkpoint'], + ['src/storage/snapshot/runSnapshotSyncSession.ts', 'copy'], + ['src/storage/StorageKnex.ts', 'storage'], + ['src/storage/WalletStorageManager.ts', 'primary'] + ]) + } + ], [ 'wallet-retained-snapshot', { diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 40095dab6..155379065 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict' import test from 'node:test' import { parseArguments, REPOSITORY_ROOT } from './mutation-testing.mjs' import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' +import { canonicalInventory, targetSources } from './mutation-final-qualification.mjs' +import { join } from 'node:path' import { partitionMutationTarget, selectedMutationPartition, @@ -19,6 +21,62 @@ const target = { ], runnerOptions: { jest: { config: { testMatch: ['all-original-tests'] } } } } + +test('primary sync execution retains every original file/range, full configuration and future helper', async () => { + const canonical = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-sync'] + const parts = partitionMutationTarget('wallet-snapshot-sync', canonical) + assert.deepEqual( + parts.map(part => part.id), + ['session', 'checkpoint', 'copy', 'storage', 'primary'] + ) + assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) + const owners = new Map() + const tuples = [] + const directory = join(REPOSITORY_ROOT, canonical.packageDirectory) + const tuple = mutant => + JSON.stringify({ + file: mutant.fileName, + name: mutant.mutatorName, + location: mutant.location, + replacement: mutant.replacement + }) + for (const part of parts) { + assert.deepEqual(part.target, { ...canonical, mutate: part.target.mutate }) + assert.equal(part.target.runnerOptions, canonical.runnerOptions) + for (const specification of part.target.mutate) { + const file = specification.replace(/:\d+(?:-\d+)?$/, '') + assert.ok(!owners.has(file) || owners.get(file) === part.id) + owners.set(file, part.id) + } + tuples.push( + ...( + await canonicalInventory( + directory, + targetSources(REPOSITORY_ROOT, part.target), + part.target.mutate + ) + ).map(tuple) + ) + } + const original = ( + await canonicalInventory(directory, targetSources(REPOSITORY_ROOT, canonical), canonical.mutate) + ).map(tuple) + assert.ok(original.length > 0) + assert.equal(new Set(tuples).size, tuples.length) + assert.deepEqual(tuples.sort(), original.sort()) + assert.equal(owners.get('src/storage/WalletStorageManager.ts'), 'primary') + assert.equal(owners.get('src/storage/StorageKnex.ts'), 'storage') + assert.equal(owners.get('src/storage/snapshot/SnapshotSync.ts'), 'session') + const helper = 'src/storage/snapshot/FuturePrimaryHelper.ts' + const future = partitionMutationTarget('wallet-snapshot-sync', { + ...canonical, + mutate: [...canonical.mutate, helper] + }) + assert.deepEqual(future[0].target.mutate, [...parts[0].target.mutate, helper]) + assert.equal(selectedMutationPartition('wallet-snapshot-sync', canonical), canonical) + const targets = buildMutationTargets(REPOSITORY_ROOT) + assert.ok(mutationExecutionMatrix(Object.keys(targets), targets).include.length <= 256) +}) test('execution partitions preserve complete original specifications and identical full test configuration', () => { const parts = partitionMutationTarget('sdk-auth-http', target) assert.deepEqual( diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 566808719..6f3cafa57 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import assert from 'node:assert/strict' import test from 'node:test' import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' @@ -348,3 +349,31 @@ test('every inherited snapshot-sync target keeps the complete journal tests for ) } }) + +test('snapshot sync owns every inherited manager region and complete primary selection', () => { + const target = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-sync'] + const file = 'src/storage/WalletStorageManager.ts' + const lines = readFileSync( + `${REPOSITORY_ROOT}/packages/wallet/wallet-toolbox/${file}`, + 'utf8' + ).split('\n') + const expected = [ + ['private async runSnapshotCopy(', 'async syncFromReader('], + ['async syncFromReader(', ' let inserts = 0'], + ['async syncFromReaderResumable(', ' const generation ='], + ['async syncToWriterResumable(', 'async syncToWriter('], + ['async syncToWriter(', ' let inserts = 0'], + ['async updateBackups(', 'async setActive('], + ['async setActive(', 'getStoreEndpointURL('], + ['private async withAccess(', 'runAsWriter('] + ].map(([startMarker, endMarker]) => { + const start = lines.findIndex(line => line.includes(startMarker)) + const end = lines.findIndex((line, index) => index > start && line.includes(endMarker)) + assert.ok(start >= 0 && end > start) + return `${file}:${start + 1}-${end}` + }) + assert.deepEqual( + target.mutate.filter(specification => specification.startsWith(`${file}:`)), + expected + ) +}) diff --git a/specs/wallet/sync-portability-program.md b/specs/wallet/sync-portability-program.md index a5972dc4f..3492c35d4 100644 --- a/specs/wallet/sync-portability-program.md +++ b/specs/wallet/sync-portability-program.md @@ -35,6 +35,18 @@ manager ownership across entire copy loops. Near-realtime backup therefore still blocks foreground wallet operations. Existing benchmarks cover pull scheduling, not this push/backup acceptance case. +Primary selection now checks apparent no-op requests inside the existing +exclusive ownership boundary. Queued B then A requests therefore finish with A +selected, including when the earlier switch fails. The progress formatter runs +before generation advancement so a formatting failure before the transition +does not invalidate an in-flight copy. Regression cases and generated queued +histories cover this correction. Partial propagation failure invalidates cached +primary authorization and managed user rows before ownership is released. Queued +requests reload persisted selections after acquiring ownership; a reload failure +preserves its cause and releases the queue. Conflicting persisted selections +refuse active authorization until explicitly reconciled. Primary reconciliation +still holds ownership across its copy loop; these corrections do not complete S1. + Removing those locks alone is insufficient. A source can change between live offset pages, and an old source reply can overlap primary replacement. Source isolation, checkpoint ownership and generation fencing must accompany yielding. From edd5545ecf6dad57fdee4b6ae69b8133fca40b27 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 3 Oct 2026 12:33:19 -0700 Subject: [PATCH 104/127] fix(ci): run pinned mutation inventory after dependency installation --- .github/workflows/ci.yml | 3 + scripts/ci-orchestration.test.mjs | 57 ++++++++++------ ...mutation-partitions-engine.integration.mjs | 67 +++++++++++++++++++ scripts/mutation-partitions.test.mjs | 42 ++---------- 4 files changed, 113 insertions(+), 56 deletions(-) create mode 100644 scripts/mutation-partitions-engine.integration.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eb2fb0b0..b4fe40bc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -257,6 +257,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts + - name: Verify pinned mutation partition inventory + run: node --test scripts/mutation-partitions-engine.integration.mjs + - name: Rebuild the audited workspace build tool run: pnpm rebuild esbuild diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index f25c9218c..b9d03c047 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -31,7 +31,7 @@ function assertWalletMutationTimeout(job, defaultMinutes) { const targets = '["revenue-lineage-package","revenue-lineage-graph","sdk-revenue-listing-funding","output-lookup-session-records","output-lookup-session-payloads","wallet-recovery-codec","wallet-recovery-installation","wallet-recovery-store","wallet-funding-store","wallet-recovery-transitions","wallet-recovery-controller","root-eviction-storage","root-eviction-journal","root-eviction-records","wallet-retained-snapshot","wallet-snapshot-journal","wallet-snapshot-sync","wallet-snapshot-sync-destination","wallet-snapshot-sync-rows","wallet-snapshot-archive","wallet-snapshot-remote-http","wallet-snapshot-remote-reader","wallet-snapshot-remote-service"]' const expected = ` timeout-minutes: \${{ contains(fromJSON('${targets}'), matrix.target) && 90 || ${defaultMinutes} }}` - assert.equal(job.source.match(/^ timeout-minutes: .+$/m)?.[0], expected) + assert.equal(job.source.match(/^ {4}timeout-minutes: .+$/m)?.[0], expected) } test('CI downloads every canonical execution partition before verifying its complete union', () => { @@ -53,7 +53,7 @@ test('CI downloads every canonical execution partition before verifying its comp assert.ok(verify > 0) const downloads = gate .slice(0, verify) - .split(/^ - /m) + .split(/^ {6}- /m) .filter(step => step.startsWith('uses: actions/download-artifact@')) for (const target of partitioned) { const selected = downloads.filter(step => @@ -193,11 +193,11 @@ test('wallet mutation allowances preserve other limits and complete campaign exe ) assert.ok(job, path) assertWalletMutationTimeout(job, defaultMinutes) - assert.match(job.source, /^ fail-fast: false$/m) + assert.match(job.source, /^ {6}fail-fast: false$/m) assert.match(job.source, new RegExp(`^ max-parallel: ${maxParallel}$`, 'm')) assert.match( job.source, - /^ run: node scripts\/mutation-testing\.mjs --target "\$\{\{ matrix\.target \}\}" --partition "\$\{\{ matrix\.partition \}\}"$/m + /^ {8}run: node scripts\/mutation-testing\.mjs --target "\$\{\{ matrix\.target \}\}" --partition "\$\{\{ matrix\.partition \}\}"$/m ) } }) @@ -418,7 +418,7 @@ function nativeWalletFixtureSteps() { const wallet = workflowJobBlocks(readFileSync(CI_PATH, 'utf8')).find( job => job.name === 'coverage-wallet' ).source - const matches = [...wallet.matchAll(/^ - /gm)] + const matches = [...wallet.matchAll(/^ {6}- /gm)] const steps = matches.map((match, index) => wallet.slice(match.index, matches[index + 1]?.index ?? wallet.length) ) @@ -429,28 +429,28 @@ function onlyFixtureStep(steps, command) { const matches = steps.filter(step => step.split('\n').includes(` run: ${command}`)) assert.equal(matches.length, 1) const step = matches[0] - assert.match(step, /^ if: matrix.id == 'shard-1'$/m) - assert.match(step, /^ working-directory: packages\/wallet\/wallet-toolbox$/m) + assert.match(step, /^ {8}if: matrix.id == 'shard-1'$/m) + assert.match(step, /^ {8}working-directory: packages\/wallet\/wallet-toolbox$/m) assert.doesNotMatch(step, /continue-on-error/) return steps.indexOf(step) } function assertNativeWalletJob(wallet) { - assert.match(wallet, /^ needs: prepare$/m) - assert.match(wallet, /^ timeout-minutes: 40$/m) - assert.match(wallet, /^ permissions:\n contents: read\n strategy:/m) + assert.match(wallet, /^ {4}needs: prepare$/m) + assert.match(wallet, /^ {4}timeout-minutes: 40$/m) + assert.match(wallet, /^ {4}permissions:\n {6}contents: read\n {4}strategy:/m) assert.doesNotMatch(wallet, /continue-on-error/) // This gate runs before dependency installation; validate the governed native // job's explicit matrix and service block without loading a workspace parser. assert.match( wallet, - /^ strategy:\n fail-fast: false\n matrix:\n include:\n - \{ id: shard-1, shard: 1 \}\n - \{ id: shard-2, shard: 2 \}\n - \{ id: shard-3, shard: 3 \}\n - \{ id: shard-4, shard: 4 \}\n - \{ id: sync-http-0, latency: 0 \}\n - \{ id: sync-http-1000, latency: 1000 \}\n services:$/m + /^ {4}strategy:\n {6}fail-fast: false\n {6}matrix:\n {8}include:\n {10}- \{ id: shard-1, shard: 1 \}\n {10}- \{ id: shard-2, shard: 2 \}\n {10}- \{ id: shard-3, shard: 3 \}\n {10}- \{ id: shard-4, shard: 4 \}\n {10}- \{ id: sync-http-0, latency: 0 \}\n {10}- \{ id: sync-http-1000, latency: 1000 \}\n {4}services:$/m ) assert.match( wallet, - /^ postgres:\n image: postgres@sha256:d5daad18926b71c3d663f358af0aea798670cb79fb550c106d19662a9d1627ef(?: #[^\n]*)?$/m + /^ {6}postgres:\n {8}image: postgres@sha256:d5daad18926b71c3d663f358af0aea798670cb79fb550c106d19662a9d1627ef(?: #[^\n]*)?$/m ) - assert.match(wallet, /^ ports:\n - 5432:5432\n options: >-$/m) + assert.match(wallet, /^ {8}ports:\n {10}- 5432:5432\n {8}options: >-$/m) } test('native snapshot process-loss proof uses the same-head build in exactly one required wallet shard', () => { @@ -474,11 +474,8 @@ test('native MySQL uses a bounded pinned fixture in the existing required wallet ) const proof = onlyFixtureStep(steps, 'node test/storage/runSnapshotArchiveMysql.cjs') for (const index of [provision, proof]) { - assert.match(steps[index], /^ timeout-minutes: 5$/m) - assert.match( - steps[index], - /^ env:\n TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1'\n run:/m - ) + assert.match(steps[index], /^ {8}timeout-minutes: 5$/m) + assert.match(steps[index], /^ {8}env:\n {10}TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1'\n {8}run:/m) } const sqlite = onlyFixtureStep(steps, 'node test/storage/snapshotArchiveCrash.cjs') const coverage = steps.findIndex(step => @@ -533,9 +530,29 @@ test('journal native recovery remains mandatory alongside all archive fixture fa const coverage = steps.findIndex(step => step.includes('name: Generate wallet-toolbox coverage shard') ) - assert.match(steps[sqlite], /^ timeout-minutes: 3$/m) - assert.match(steps[mysql], /^ timeout-minutes: 10$/m) + assert.match(steps[sqlite], /^ {8}timeout-minutes: 3$/m) + assert.match(steps[mysql], /^ {8}timeout-minutes: 10$/m) assert.ok(steps[mysql].includes("TS_STACK_SNAPSHOT_HOSTED_MYSQL: '1'")) assert.ok(archive < sqlite && sqlite < mysql && mysql < coverage) assertNativeWalletJob(wallet) }) + +test('actual pinned inventory runs as a required installed-tool regression before build reuse', () => { + const jobs = workflowJobBlocks(readFileSync(CI_PATH, 'utf8')) + const health = jobs.find(job => job.name === 'repository-health').source + const prepare = jobs.find(job => job.name === 'prepare').source + assert.ok(health.includes('node --test scripts/*.test.mjs')) + assert.ok(!health.includes('mutation-partitions-engine.integration.mjs')) + const install = prepare.indexOf('run: pnpm install --frozen-lockfile --ignore-scripts') + const verify = prepare.indexOf( + 'run: node --test scripts/mutation-partitions-engine.integration.mjs' + ) + const build = prepare.indexOf('- name: Build workspace') + assert.ok(install >= 0 && install < verify && verify < build) + const steps = prepare.split(/^ {6}- /m) + const selected = steps.filter(step => + step.includes('run: node --test scripts/mutation-partitions-engine.integration.mjs') + ) + assert.equal(selected.length, 1) + assert.ok(!selected[0].includes('if:') && !selected[0].includes('continue-on-error')) +}) diff --git a/scripts/mutation-partitions-engine.integration.mjs b/scripts/mutation-partitions-engine.integration.mjs new file mode 100644 index 000000000..9e45ea397 --- /dev/null +++ b/scripts/mutation-partitions-engine.integration.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { join } from 'node:path' +import { REPOSITORY_ROOT } from './mutation-testing.mjs' +import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' +import { canonicalInventory, targetSources } from './mutation-final-qualification.mjs' +import { + partitionMutationTarget, + selectedMutationPartition, + mutationExecutionMatrix +} from './mutation-partitions.mjs' + +test('primary sync execution retains every original file/range, full configuration and future helper', async () => { + const canonical = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-sync'] + const parts = partitionMutationTarget('wallet-snapshot-sync', canonical) + assert.deepEqual( + parts.map(part => part.id), + ['session', 'checkpoint', 'copy', 'storage', 'primary'] + ) + assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) + const owners = new Map() + const tuples = [] + const directory = join(REPOSITORY_ROOT, canonical.packageDirectory) + const tuple = mutant => + JSON.stringify({ + file: mutant.fileName, + name: mutant.mutatorName, + location: mutant.location, + replacement: mutant.replacement + }) + for (const part of parts) { + assert.deepEqual(part.target, { ...canonical, mutate: part.target.mutate }) + assert.equal(part.target.runnerOptions, canonical.runnerOptions) + for (const specification of part.target.mutate) { + const file = specification.replace(/:\d+(?:-\d+)?$/, '') + assert.ok(!owners.has(file) || owners.get(file) === part.id) + owners.set(file, part.id) + } + tuples.push( + ...( + await canonicalInventory( + directory, + targetSources(REPOSITORY_ROOT, part.target), + part.target.mutate + ) + ).map(tuple) + ) + } + const original = ( + await canonicalInventory(directory, targetSources(REPOSITORY_ROOT, canonical), canonical.mutate) + ).map(tuple) + assert.ok(original.length > 0) + assert.equal(new Set(tuples).size, tuples.length) + assert.deepEqual(tuples.sort(), original.sort()) + assert.equal(owners.get('src/storage/WalletStorageManager.ts'), 'primary') + assert.equal(owners.get('src/storage/StorageKnex.ts'), 'storage') + assert.equal(owners.get('src/storage/snapshot/SnapshotSync.ts'), 'session') + const helper = 'src/storage/snapshot/FuturePrimaryHelper.ts' + const future = partitionMutationTarget('wallet-snapshot-sync', { + ...canonical, + mutate: [...canonical.mutate, helper] + }) + assert.deepEqual(future[0].target.mutate, [...parts[0].target.mutate, helper]) + assert.equal(selectedMutationPartition('wallet-snapshot-sync', canonical), canonical) + const targets = buildMutationTargets(REPOSITORY_ROOT) + assert.ok(mutationExecutionMatrix(Object.keys(targets), targets).include.length <= 256) +}) diff --git a/scripts/mutation-partitions.test.mjs b/scripts/mutation-partitions.test.mjs index 155379065..a25db5f24 100644 --- a/scripts/mutation-partitions.test.mjs +++ b/scripts/mutation-partitions.test.mjs @@ -2,8 +2,6 @@ import assert from 'node:assert/strict' import test from 'node:test' import { parseArguments, REPOSITORY_ROOT } from './mutation-testing.mjs' import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' -import { canonicalInventory, targetSources } from './mutation-final-qualification.mjs' -import { join } from 'node:path' import { partitionMutationTarget, selectedMutationPartition, @@ -22,7 +20,7 @@ const target = { runnerOptions: { jest: { config: { testMatch: ['all-original-tests'] } } } } -test('primary sync execution retains every original file/range, full configuration and future helper', async () => { +test('primary sync partitions preserve complete contracts before dependencies are installed', () => { const canonical = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-sync'] const parts = partitionMutationTarget('wallet-snapshot-sync', canonical) assert.deepEqual( @@ -31,15 +29,6 @@ test('primary sync execution retains every original file/range, full configurati ) assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) const owners = new Map() - const tuples = [] - const directory = join(REPOSITORY_ROOT, canonical.packageDirectory) - const tuple = mutant => - JSON.stringify({ - file: mutant.fileName, - name: mutant.mutatorName, - location: mutant.location, - replacement: mutant.replacement - }) for (const part of parts) { assert.deepEqual(part.target, { ...canonical, mutate: part.target.mutate }) assert.equal(part.target.runnerOptions, canonical.runnerOptions) @@ -48,22 +37,7 @@ test('primary sync execution retains every original file/range, full configurati assert.ok(!owners.has(file) || owners.get(file) === part.id) owners.set(file, part.id) } - tuples.push( - ...( - await canonicalInventory( - directory, - targetSources(REPOSITORY_ROOT, part.target), - part.target.mutate - ) - ).map(tuple) - ) } - const original = ( - await canonicalInventory(directory, targetSources(REPOSITORY_ROOT, canonical), canonical.mutate) - ).map(tuple) - assert.ok(original.length > 0) - assert.equal(new Set(tuples).size, tuples.length) - assert.deepEqual(tuples.sort(), original.sort()) assert.equal(owners.get('src/storage/WalletStorageManager.ts'), 'primary') assert.equal(owners.get('src/storage/StorageKnex.ts'), 'storage') assert.equal(owners.get('src/storage/snapshot/SnapshotSync.ts'), 'session') @@ -77,6 +51,7 @@ test('primary sync execution retains every original file/range, full configurati const targets = buildMutationTargets(REPOSITORY_ROOT) assert.ok(mutationExecutionMatrix(Object.keys(targets), targets).include.length <= 256) }) + test('execution partitions preserve complete original specifications and identical full test configuration', () => { const parts = partitionMutationTarget('sdk-auth-http', target) assert.deepEqual( @@ -212,9 +187,7 @@ test('service execution preserves the complete canonical union and all configura assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) assert.equal(new Set(parts.flatMap(part => part.target.mutate)).size, canonical.mutate.length) for (const part of parts) { - const { mutate: _partMutate, ...partConfig } = part.target - const { mutate: _canonicalMutate, ...canonicalConfig } = canonical - assert.deepEqual(partConfig, canonicalConfig) + assert.deepEqual(part.target, { ...canonical, mutate: part.target.mutate }) assert.equal(part.target.runnerOptions, canonical.runnerOptions) } assert.deepEqual(parts[1].target.mutate, [ @@ -266,9 +239,7 @@ test('HTTP execution preserves every canonical range, full configuration and fut assert.deepEqual([...actual].sort(), original) assert.equal(new Set(actual).size, original.length) for (const part of parts) { - const { mutate: _partMutate, ...partConfig } = part.target - const { mutate: _canonicalMutate, ...canonicalConfig } = canonical - assert.deepEqual(partConfig, canonicalConfig) + assert.deepEqual(part.target, { ...canonical, mutate: part.target.mutate }) assert.equal(part.target.runnerOptions, canonical.runnerOptions) } assert.deepEqual(parts[0].target.mutate, [ @@ -377,9 +348,8 @@ for (const [id, expected, fallback] of [ assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) const owners = new Map() for (const part of parts) { - const { mutate, ...configuration } = part.target - const { mutate: _canonicalMutate, ...canonicalConfiguration } = canonical - assert.deepEqual(configuration, canonicalConfiguration) + const { mutate } = part.target + assert.deepEqual(part.target, { ...canonical, mutate }) assert.equal(part.target.runnerOptions, canonical.runnerOptions) for (const specification of mutate) { const file = specification.replace(/:\d+(?:-\d+)?$/, '') From 2c812005183d3446695cf26d736396608aca50b0 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 3 Oct 2026 12:47:37 -0700 Subject: [PATCH 105/127] test(ci): preserve serial inventory checks with typed analyzer rules --- ...mutation-partitions-engine.integration.mjs | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/scripts/mutation-partitions-engine.integration.mjs b/scripts/mutation-partitions-engine.integration.mjs index 9e45ea397..1316543f7 100644 --- a/scripts/mutation-partitions-engine.integration.mjs +++ b/scripts/mutation-partitions-engine.integration.mjs @@ -10,6 +10,21 @@ import { mutationExecutionMatrix } from './mutation-partitions.mjs' +function lexicalCompare(left, right) { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +async function* inventories(directory, parts) { + for (const part of parts) + yield canonicalInventory( + directory, + targetSources(REPOSITORY_ROOT, part.target), + part.target.mutate + ) +} + test('primary sync execution retains every original file/range, full configuration and future helper', async () => { const canonical = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-sync'] const parts = partitionMutationTarget('wallet-snapshot-sync', canonical) @@ -17,7 +32,10 @@ test('primary sync execution retains every original file/range, full configurati parts.map(part => part.id), ['session', 'checkpoint', 'copy', 'storage', 'primary'] ) - assert.deepEqual(parts.flatMap(part => part.target.mutate).sort(), [...canonical.mutate].sort()) + assert.deepEqual( + parts.flatMap(part => part.target.mutate).toSorted(lexicalCompare), + [...canonical.mutate].toSorted(lexicalCompare) + ) const owners = new Map() const tuples = [] const directory = join(REPOSITORY_ROOT, canonical.packageDirectory) @@ -36,22 +54,14 @@ test('primary sync execution retains every original file/range, full configurati assert.ok(!owners.has(file) || owners.get(file) === part.id) owners.set(file, part.id) } - tuples.push( - ...( - await canonicalInventory( - directory, - targetSources(REPOSITORY_ROOT, part.target), - part.target.mutate - ) - ).map(tuple) - ) } + for await (const mutants of inventories(directory, parts)) tuples.push(...mutants.map(tuple)) const original = ( await canonicalInventory(directory, targetSources(REPOSITORY_ROOT, canonical), canonical.mutate) ).map(tuple) assert.ok(original.length > 0) assert.equal(new Set(tuples).size, tuples.length) - assert.deepEqual(tuples.sort(), original.sort()) + assert.deepEqual(tuples.toSorted(lexicalCompare), original.toSorted(lexicalCompare)) assert.equal(owners.get('src/storage/WalletStorageManager.ts'), 'primary') assert.equal(owners.get('src/storage/StorageKnex.ts'), 'storage') assert.equal(owners.get('src/storage/snapshot/SnapshotSync.ts'), 'session') From a5c7e74218c992ecd4e7bf942134a7cbf1e8afad Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 3 Oct 2026 14:33:58 -0700 Subject: [PATCH 106/127] fix(wallet): preserve consistent reads in MySQL snapshots --- docs/reference/package-api-migrations.md | 2 +- governance/package-release-notes.json | 2 +- packages/wallet/wallet-toolbox/README.md | 5 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 32 ++++-- .../storage/portable/mysqlSnapshot.test.ts | 97 ++++++++++++++++++- .../test/storage/snapshotArchiveMysql.cjs | 13 +++ 6 files changed, 140 insertions(+), 11 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 1dde3b490..59d215aaf 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -514,7 +514,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. - Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 01d209260..3de847d71 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,7 +210,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry.", + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles.", "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation." }, { diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 02958ccaf..8361d8226 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -38,7 +38,10 @@ The unpublished 2.15 candidate captures BRC-38 source settings, wallet identity and standard table closure in one local provider read view. SQLite and IndexedDB tests cover independent writes during capture. A local MySQL 8.4.11 fixture verifies repeatable-read isolation, read-only enforcement and connection cleanup -without changing session defaults; deployed/PXC recovery remains unqualified. Custom providers +without changing session defaults. Exact proof and checkpoint reads inside these +provider-owned snapshots retain the consistent view without requesting write +locks; ordinary writable transactions retain their row locks. Deployed/PXC +recovery remains unqualified. Custom providers opt in with `supportsReadSnapshot` and `readSnapshot`. Use the export option `requireSnapshot: true` to refuse unsupported capture; old custom-provider calls retain their documented caller-quiesced fallback. diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index b2ce3b4f1..501f7cdef 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -139,6 +139,20 @@ export interface StorageKnexOptions extends StorageProviderOptions { const ACTION_BATCH_BLOB_SQL_CHUNK = 500 const OUTPUT_INSERT_SQL_CHUNK = 500 +// Only provider-owned snapshot callbacks can classify a transaction as a read +// view. Keep this private and shared across providers that use the same token. +const readSnapshotTransactions = new WeakSet() + +async function withinReadSnapshot(trx: TrxToken, read: (trx: TrxToken) => Promise): Promise { + const alreadyTracked = readSnapshotTransactions.has(trx) + readSnapshotTransactions.add(trx) + try { + return await read(trx) + } finally { + if (!alreadyTracked) readSnapshotTransactions.delete(trx) + } +} + interface KnexTelemetryQuery { __knexQueryUid?: string method?: string @@ -242,10 +256,10 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide // SQLite establishes its read view on the first query. Do not request // Knex's unsupported SQLite isolation/readOnly options or write settings // into the shared connection. WAL writers may use another connection. - return await this.knex.transaction(read) + return await this.knex.transaction(trx => withinReadSnapshot(trx, read)) } if (database === 'mysql') { - return await this.readMySQLSnapshot(read) + return await this.readMySQLSnapshot(trx => withinReadSnapshot(trx, read)) } throw new WERR_NOT_IMPLEMENTED('Coherent wallet source snapshots require SQLite or MySQL isolation') } @@ -271,7 +285,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide throw new WERR_INVALID_OPERATION('This provider already has a retained read snapshot opening or active') } const lifetime = retainReadSnapshot( - transaction, + read => transaction(trx => withinReadSnapshot(trx, read)), async trx => { // Pin SQLite's deferred read view before opening resolves. MySQL also // establishes its repeatable-read snapshot on this first data read. @@ -2022,9 +2036,14 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide override async findProvenTxs(args: FindProvenTxsArgs): Promise { const q = this.findProvenTxsQuery(args) - // A transactional exact-proof lookup is a read/modify/write authority - // check. Lock that row until commit so monitor and sync repairs cannot race. - if (args.trx != null && this.dbtype === 'MySQL' && (args.partial.txid != null || args.partial.provenTxId != null)) + // Writable exact-proof lookups are read/modify/write authority checks. + // Snapshot views use consistent reads; MySQL rejects FOR UPDATE in READ ONLY. + if ( + args.trx != null && + !readSnapshotTransactions.has(args.trx) && + this.dbtype === 'MySQL' && + (args.partial.txid != null || args.partial.provenTxId != null) + ) q.forUpdate() const r = await q return this.validateEntities(r) @@ -2041,6 +2060,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide // Serialize sync checkpoint reads with the page transaction on MySQL and Postgres too. if ( args.trx != null && + !readSnapshotTransactions.has(args.trx) && (this.dbtype === 'MySQL' || this.dbtype === 'Postgres') && args.partial.userId != null && (args.partial.syncStateId != null || args.partial.storageIdentityKey != null) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts index 38496736a..9d02101c3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts @@ -1,6 +1,7 @@ import { knex, type Knex } from 'knex' import { StorageKnex } from '../StorageKnex' import { StorageProvider } from '../StorageProvider' +import type { TrxToken } from '../../sdk/WalletStorage.interfaces' function mysqlFixture(failAt?: string) { const db = knex({ client: 'mysql2' }) @@ -31,7 +32,7 @@ function mysqlFixture(failAt?: string) { events.push('RELEASE') return Promise.resolve() }) - jest.spyOn(storage, 'makeAvailable').mockResolvedValue({ + storage._settings = { created_at: new Date(0), updated_at: new Date(0), storageIdentityKey: 'synthetic-storage', @@ -39,7 +40,8 @@ function mysqlFixture(failAt?: string) { chain: 'test', dbtype: 'MySQL', maxOutputScript: 1024 - }) + } + jest.spyOn(storage, 'makeAvailable').mockResolvedValue(storage._settings) return { storage, db, events, failure, acquire } } @@ -65,6 +67,97 @@ test('MySQL snapshot uses one reserved connection and valid next-transaction cha } }) +const exactReads = [ + ['proof id', (storage: StorageKnex, trx: TrxToken) => storage.findProvenTxs({ partial: { provenTxId: 1 }, trx })], + [ + 'proof txid', + (storage: StorageKnex, trx: TrxToken) => storage.findProvenTxs({ partial: { txid: '11'.repeat(32) }, trx }) + ], + [ + 'checkpoint id', + (storage: StorageKnex, trx: TrxToken) => storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 }, trx }) + ], + [ + 'checkpoint identity', + (storage: StorageKnex, trx: TrxToken) => + storage.findSyncStates({ partial: { userId: 1, storageIdentityKey: 'source' }, trx }) + ] +] as const + +test.each(exactReads)( + 'MySQL snapshot %s uses a consistent read while writable transactions still lock', + async (_name, read) => { + const fixture = mysqlFixture() + try { + await fixture.storage.readSnapshot(async trx => { + await read(fixture.storage, trx) + }) + expect(fixture.events.filter(sql => sql.startsWith('SELECT'))).toHaveLength(1) + expect(fixture.events.some(sql => sql.endsWith('FOR UPDATE'))).toBe(false) + fixture.events.length = 0 + await fixture.db.transaction(async trx => { + await read(fixture.storage, trx) + }) + expect(fixture.events.filter(sql => sql.endsWith('FOR UPDATE'))).toHaveLength(1) + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } + } +) + +test('snapshot classification follows its token across providers without classifying another transaction', async () => { + const fixture = mysqlFixture() + const other = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: fixture.db }) + other._settings = fixture.storage._settings + try { + await fixture.storage.readSnapshot(async trx => { + await other.findProvenTxs({ partial: { provenTxId: 1 }, trx }) + await fixture.db.transaction(write => other.findProvenTxs({ partial: { provenTxId: 1 }, trx: write })) + await other.findProvenTxs({ partial: { provenTxId: 1 }, trx }) + }) + const selects = fixture.events.filter(sql => sql.startsWith('SELECT')) + expect(selects).toHaveLength(3) + expect(selects.map(sql => sql.endsWith('FOR UPDATE'))).toEqual([false, true, false]) + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } +}) + +test.each([false, true])( + 'retained MySQL views keep exact reads consistent through callback settlement (failure=%s)', + async fail => { + const fixture = mysqlFixture() + const failure = new Error('retained read failed') + jest.spyOn(fixture.storage, 'readSettings').mockResolvedValue(fixture.storage.getSettings()) + try { + const view = await fixture.storage.openReadSnapshot({ lifetimeMs: 30000 }) + const result = view.read(async trx => { + await fixture.storage.findProvenTxs({ partial: { provenTxId: 1 }, trx }) + await fixture.storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 }, trx }) + if (fail) throw failure + return 19 + }) + if (fail) { + await expect(result).rejects.toBe(failure) + await expect(view.close()).rejects.toBe(failure) + } else { + await expect(result).resolves.toBe(19) + await view.close() + } + expect(fixture.events.filter(sql => sql.startsWith('SELECT'))).toHaveLength(2) + expect(fixture.events.some(sql => sql.endsWith('FOR UPDATE'))).toBe(false) + fixture.events.length = 0 + await fixture.db.transaction(trx => fixture.storage.findProvenTxs({ partial: { provenTxId: 1 }, trx })) + expect(fixture.events.filter(sql => sql.endsWith('FOR UPDATE'))).toHaveLength(1) + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } + } +) + test.each(['SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY', 'BEGIN', 'SELECT 1', 'COMMIT'])( 'MySQL snapshot discards a failed connection before returning it to the pool (%s)', async failAt => { diff --git a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs index d321551e3..9034e790e 100644 --- a/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs +++ b/packages/wallet/wallet-toolbox/test/storage/snapshotArchiveMysql.cjs @@ -32,6 +32,7 @@ const { const { addSnapshotArchiveTables } = require('../../out/src/storage/schema/snapshotArchiveMigration.js') const { StorageKnex } = require('../../out/src/storage/StorageKnex.js') const { StorageProvider } = require('../../out/src/storage/StorageProvider.js') +const { exportBRC38 } = require('../../out/src/storage/portable/index.js') const { captureKnexSnapshotArchive } = require('../../out/src/storage/snapshot/archive/captureKnexSnapshotArchive.js') const { decodeSyncTransfer } = require('../../out/src/storage/remoting/SyncTransfer.js') const { KnexSnapshotArchiveService } = require('../../out/src/storage/snapshot/archive/KnexSnapshotArchiveService.js') @@ -127,6 +128,17 @@ async function captureFixture() { reference: 'foreign' }) await writer.knex('users').where({ userId: user.userId }).update({ activeStorage: 'historical selection' }) + const portable = await exportBRC38(reader, identity, { requireSnapshot: true }) + assert.equal(portable.user.activeStorage, 'historical selection') + assert.equal(portable.tables.provenTxs.length, 1) + assert.equal(portable.tables.provenTxs[0].rawTx, 'AQL/') + await reader.readSnapshot(async trx => { + assert.equal((await reader.findProvenTxs({ partial: { provenTxId: proof }, trx })).length, 1) + assert.deepEqual( + await reader.findSyncStates({ partial: { userId: user.userId, storageIdentityKey: 'native-source' }, trx }), + [] + ) + }) let changedDuringCapture = false KnexSnapshotArchiveStore.prototype.append = async function (owner, page, authorize) { await originalAppend.call(this, owner, page, authorize) @@ -191,6 +203,7 @@ async function captureFixture() { originalPrimary: true, originalSchema: true, packedBinary: true, + portableProofAndCheckpointReadOnly: true, crossProfileClosureRejected: true, requestLifecycle, remoteReader, From 3b1fb18a88a5b43d4453767a6c0ebb7f4a03e2bc Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 3 Oct 2026 15:26:02 -0700 Subject: [PATCH 107/127] test(wallet): govern coherent snapshot read consistency --- docs/reference/package-api-migrations.md | 2 +- governance/mutation-testing/policy.json | 10 ++ governance/mutation-testing/targets.mjs | 69 +++++++ governance/package-release-notes.json | 2 +- governance/test-quality/policy.json | 14 ++ packages/wallet/wallet-toolbox/README.md | 10 +- packages/wallet/wallet-toolbox/package.json | 2 +- .../mysqlReadSnapshot.property.test.ts | 100 +++++++++++ .../storage/portable/mysqlSnapshot.test.ts | 168 +++++++++++++----- .../StorageKnex.retainedSnapshot.test.ts | 1 + .../test/utils/mysqlReadSnapshotFixture.ts | 45 +++++ scripts/test-governance.test.mjs | 4 +- 12 files changed, 374 insertions(+), 53 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/mysqlReadSnapshot.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/test/utils/mysqlReadSnapshotFixture.ts diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 59d215aaf..305a979fc 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -514,7 +514,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. - Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 64579443d..fe20695c5 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -486,6 +486,16 @@ "minimumScore": 90, "maximumNoCoverage": 0, "maximumInvalid": 0 + }, + { + "id": "wallet-read-snapshot-consistency", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/mysqlReadSnapshot.property.test.ts", + "risk": "critical", + "boundary": "Wallet callback-owned coherent transaction classification, exact proof/checkpoint consistent reads and preservation of writable authority locks", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 } ] } diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index cabe1d468..40e5db2c4 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -425,6 +425,75 @@ export function buildMutationTargets(repositoryRoot) { } ) }, + 'wallet-read-snapshot-consistency': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + additionalInputs: [ + 'test/storage/snapshotArchiveMysql.cjs', + 'test/storage/runSnapshotArchiveMysql.cjs', + 'test/storage/snapshotArchiveDocker.cjs', + 'test/storage/snapshotMysqlFixtureGroups.cjs', + 'test/utils/mysqlReadSnapshotFixture.ts' + ], + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/portable/mysqlReadSnapshot.property.test.ts', + mutate: [ + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + '// Only provider-owned snapshot callbacks', + 'interface KnexTelemetryQuery' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override async readSnapshot', + 'override supportsRetainedReadSnapshot(): boolean' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'private async readMySQLSnapshot', + 'override getSnapshotSync():' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override async findProvenTxs(', + 'override async findStaleMerkleRoots(' + ), + sourceLineRange( + repositoryRoot, + 'packages/wallet/wallet-toolbox', + 'src/storage/StorageKnex.ts', + 'override async findSyncStates(', + 'override async findTransactions(' + ) + ], + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/portable/snapshot.test.ts', + '/src/storage/portable/mysqlSnapshot.test.ts', + '/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts', + '/src/storage/snapshot/KnexWalletReadSnapshot.test.ts', + '/src/storage/snapshot/RetainedReadSnapshot.property.test.ts', + '/src/storage/portable/mysqlReadSnapshot.property.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + }, 'wallet-retained-snapshot': { packageDirectory: 'packages/wallet/wallet-toolbox', manifest: 'packages/wallet/wallet-toolbox/package.json', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 3de847d71..7b1087840 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,7 +210,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles.", + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy.", "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation." }, { diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index c61e36222..9cc9e67ed 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -650,6 +650,20 @@ "Same-position retries reproduce identical rows, cursor and allocation charge.", "A cursor for another archive is rejected and each opened reader cancels its original immutable request exactly once." ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/mysqlReadSnapshot.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet callback-owned coherent transaction classification, exact proof/checkpoint consistent reads and preservation of writable authority locks", + "target": "Generated direct/retained read schedules across providers with interleaved independent writable transactions, partial/exact selectors and callback failures", + "invariants": [ + "Provider-owned coherent snapshots never request writable row locks.", + "Independent ordinary transactions preserve each existing exact-selector lock requirement.", + "Snapshot classification follows the transaction token across provider instances and never marks an independent transaction.", + "Direct and retained callbacks preserve exact failure causes and release their database connection.", + "Every generated schedule preserves the complete selector/query count without an unbounded queue." + ] } ], "exclusions": [ diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 8361d8226..c4a32143b 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -40,8 +40,14 @@ tests cover independent writes during capture. A local MySQL 8.4.11 fixture verifies repeatable-read isolation, read-only enforcement and connection cleanup without changing session defaults. Exact proof and checkpoint reads inside these provider-owned snapshots retain the consistent view without requesting write -locks; ordinary writable transactions retain their row locks. Deployed/PXC -recovery remains unqualified. Custom providers +locks; ordinary writable transactions retain their row locks. The critical +`wallet-read-snapshot-consistency` mutation target covers the complete transaction +classification and lookup methods with all six retained, portable and generated +read-view suites. Generated schedules exercise direct/retained failure cleanup, +cross-provider tokens, partial/exact selectors and independent writable work; +ordinary lookups also preserve returned checkpoint date/boolean normalization. +The original property strength and zero-uncovered/invalid/unexecuted mutation +gates apply. Deployed/PXC recovery remains unqualified. Custom providers opt in with `supportsReadSnapshot` and `readSnapshot`. Use the export option `requireSnapshot: true` to refuse unsupported capture; old custom-provider calls retain their documented caller-quiesced fallback. diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 98a573d2a..400e3b22d 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/mysqlReadSnapshot.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/mysqlReadSnapshot.property.test.ts new file mode 100644 index 000000000..3fb2f1d8a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/mysqlReadSnapshot.property.test.ts @@ -0,0 +1,100 @@ +import fc from 'fast-check' +import type { TrxToken } from '../../sdk/WalletStorage.interfaces' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { runInSeries } from '../../utility/runInSeries' +import { mysqlReadSnapshotFixture } from '../../../test/utils/mysqlReadSnapshotFixture' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +const reads: ReadonlyArray<(storage: StorageKnex, trx: TrxToken) => Promise> = [ + (storage, trx) => storage.findProvenTxs({ partial: { provenTxId: 1 }, trx }), + (storage, trx) => storage.findProvenTxs({ partial: { txid: '11'.repeat(32) }, trx }), + (storage, trx) => storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 }, trx }), + (storage, trx) => storage.findSyncStates({ partial: { userId: 1, storageIdentityKey: 'source' }, trx }), + (storage, trx) => storage.findProvenTxs({ partial: {}, trx }), + (storage, trx) => storage.findSyncStates({ partial: { userId: 1 }, trx }), + (storage, trx) => storage.findSyncStates({ partial: { syncStateId: 1 }, trx }) +] + +test('generated direct and retained view schedules preserve consistent reads, writable locks and exact failures', async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + retained: fc.boolean(), + fail: fc.boolean(), + schedule: fc.array( + fc.record({ + selector: fc.integer({ min: 0, max: reads.length - 1 }), + peer: fc.boolean(), + write: fc.boolean() + }), + { minLength: 1, maxLength: 24 } + ) + }), + async input => { + // The installed Knex transaction/query builders run against a bounded + // synthetic driver. The separate native fixture proves server isolation. + const fixture = mysqlReadSnapshotFixture() + const peer = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: fixture.db }) + peer._settings = fixture.storage.getSettings() + jest.spyOn(fixture.storage, 'readSettings').mockResolvedValue(fixture.storage.getSettings()) + const failure = new Error('generated callback failure') + const expectedLocks: boolean[] = [] + const read = async (trx: TrxToken): Promise => { + await runInSeries(input.schedule, async operation => { + const storage = operation.peer ? peer : fixture.storage + await reads[operation.selector](storage, trx) + expectedLocks.push(false) + if (operation.write) { + await fixture.db.transaction(async writable => { + await reads[operation.selector](storage, writable) + }) + // Only the four exact writable authority selectors require locks. + expectedLocks.push(operation.selector < 4) + } + }) + if (input.fail) throw failure + return input.schedule.length + } + try { + if (input.retained) { + const view = await fixture.storage.openReadSnapshot({ lifetimeMs: 30000 }) + const result = view.read(read) + if (input.fail) { + await expect(result).rejects.toBe(failure) + await expect(view.close()).rejects.toBe(failure) + } else { + await expect(result).resolves.toBe(input.schedule.length) + await view.close() + } + } else { + const result = fixture.storage.readSnapshot(read) + if (input.fail) await expect(result).rejects.toBe(failure) + else await expect(result).resolves.toBe(input.schedule.length) + } + await fixture.db.transaction(async trx => { + await fixture.storage.findProvenTxs({ partial: { provenTxId: 1 }, trx }) + }) + expectedLocks.push(true) + const actual = fixture.events.filter(sql => sql.startsWith('SELECT')) + expect(actual).toHaveLength(expectedLocks.length) + expect(actual.map(sql => sql.endsWith('FOR UPDATE'))).toEqual(expectedLocks) + expect(fixture.events.at(-1)).toBe('RELEASE') + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } + } + ), + { interruptAfterTimeLimit: 150000, markInterruptAsFailure: true } + ) +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts index 9d02101c3..ae38fb655 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/mysqlSnapshot.test.ts @@ -1,50 +1,9 @@ import { knex, type Knex } from 'knex' +import { mysqlReadSnapshotFixture as mysqlFixture } from '../../../test/utils/mysqlReadSnapshotFixture' import { StorageKnex } from '../StorageKnex' import { StorageProvider } from '../StorageProvider' import type { TrxToken } from '../../sdk/WalletStorage.interfaces' -function mysqlFixture(failAt?: string) { - const db = knex({ client: 'mysql2' }) - const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: db }) - const events: string[] = [] - const failure = new Error('synthetic database failure') - const connection = { - destroy() { - events.push('CLOSE') - }, - query( - query: { sql: string }, - _bindings: unknown, - callback: (error: Error | null, rows?: unknown[], fields?: unknown[]) => void - ) { - const sql = query.sql.toUpperCase().replace(/;$/, '') - events.push(sql) - if (sql === failAt) return callback(failure) - // Exercise the actual installed Knex transaction builder against MySQL's - // comma-separated characteristic grammar. The combined options emitted - // invalid syntax before this regression was found with real MySQL 8.4. - if (sql.includes('REPEATABLE READ READ ONLY')) return callback(new Error('invalid MySQL syntax')) - callback(null, [], []) - } - } - const acquire = jest.spyOn(db.client, 'acquireConnection').mockResolvedValue(connection) - jest.spyOn(db.client, 'releaseConnection').mockImplementation(() => { - events.push('RELEASE') - return Promise.resolve() - }) - storage._settings = { - created_at: new Date(0), - updated_at: new Date(0), - storageIdentityKey: 'synthetic-storage', - storageName: 'synthetic MySQL', - chain: 'test', - dbtype: 'MySQL', - maxOutputScript: 1024 - } - jest.spyOn(storage, 'makeAvailable').mockResolvedValue(storage._settings) - return { storage, db, events, failure, acquire } -} - test('MySQL snapshot uses one reserved connection and valid next-transaction characteristics', async () => { const fixture = mysqlFixture() try { @@ -68,22 +27,139 @@ test('MySQL snapshot uses one reserved connection and valid next-transaction cha }) const exactReads = [ - ['proof id', (storage: StorageKnex, trx: TrxToken) => storage.findProvenTxs({ partial: { provenTxId: 1 }, trx })], + ['proof id', (storage: StorageKnex, trx?: TrxToken) => storage.findProvenTxs({ partial: { provenTxId: 1 }, trx })], [ 'proof txid', - (storage: StorageKnex, trx: TrxToken) => storage.findProvenTxs({ partial: { txid: '11'.repeat(32) }, trx }) + (storage: StorageKnex, trx?: TrxToken) => storage.findProvenTxs({ partial: { txid: '11'.repeat(32) }, trx }) ], [ 'checkpoint id', - (storage: StorageKnex, trx: TrxToken) => storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 }, trx }) + (storage: StorageKnex, trx?: TrxToken) => storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 }, trx }) ], [ 'checkpoint identity', - (storage: StorageKnex, trx: TrxToken) => + (storage: StorageKnex, trx?: TrxToken) => storage.findSyncStates({ partial: { userId: 1, storageIdentityKey: 'source' }, trx }) ] ] as const +test('ordinary Postgres checkpoint metadata retains its lock while proof reads preserve their existing policy', async () => { + const fixture = mysqlFixture() + fixture.storage.getSettings().dbtype = 'Postgres' + try { + await fixture.db.transaction(async trx => { + await fixture.storage.findProvenTxs({ partial: { provenTxId: 1 }, trx }) + await fixture.storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 }, trx }) + }) + const queries = fixture.events.filter(sql => sql.startsWith('SELECT')) + expect(queries).toHaveLength(2) + expect(queries.map(sql => sql.endsWith('FOR UPDATE'))).toEqual([false, true]) + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } +}) + +test('unsupported Postgres snapshots reject before opening a database transaction', async () => { + const db = knex({ client: 'pg' }) + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: db }) + const fixture = mysqlFixture() + storage._settings = { ...fixture.storage.getSettings(), dbtype: 'Postgres' } + const transaction = jest.spyOn(db.client, 'transaction') + const read = jest.fn(async () => 7) + try { + await expect(storage.readSnapshot(read)).rejects.toThrow( + 'Coherent wallet source snapshots require SQLite or MySQL isolation' + ) + expect(transaction).not.toHaveBeenCalled() + expect(read).not.toHaveBeenCalled() + } finally { + await db.destroy() + await fixture.db.destroy() + jest.restoreAllMocks() + } +}) + +test('a completed transaction is refused before the snapshot callback and its reserved connection is discarded', async () => { + const fixture = mysqlFixture() + let completed: Knex.Transaction | undefined + const original = Object.getOwnPropertyDescriptor(fixture.db, 'transaction') + if (original === undefined) throw new Error('Expected the installed Knex transaction delegate') + try { + await fixture.db.transaction(async trx => { + completed = trx + }) + if (completed === undefined) throw new Error('Expected a completed fixture transaction') + expect(completed.isCompleted()).toBe(true) + const expired = completed + fixture.events.length = 0 + // The installed Knex delegate is configurable but not writable. Preserve + // its complete descriptor while injecting a late completed callback token. + Object.defineProperty(fixture.db, 'transaction', { + ...original, + value: async (callback: (trx: Knex.Transaction) => Promise) => await callback(expired) + }) + const read = jest.fn(async () => 7) + await expect(fixture.storage.readSnapshot(read)).rejects.toThrow('MySQL snapshot transaction did not begin') + expect(read).not.toHaveBeenCalled() + expect(fixture.events).toEqual(['SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY', 'CLOSE', 'RELEASE']) + } finally { + Object.defineProperty(fixture.db, 'transaction', original) + await fixture.db.destroy() + jest.restoreAllMocks() + } +}) + +test.each(exactReads)('MySQL %s outside a transaction preserves its ordinary unlocked read', async (_name, read) => { + const fixture = mysqlFixture() + try { + await read(fixture.storage) + const queries = fixture.events.filter(sql => sql.startsWith('SELECT')) + expect(queries).toHaveLength(1) + expect(queries[0].endsWith('FOR UPDATE')).toBe(false) + expect(fixture.events).not.toContain('BEGIN') + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } +}) + +test.each([0, 1])( + 'checkpoint rows retain date and boolean normalization in both transaction modes (init=%s)', + async init => { + const when = '2026-10-03T00:00:00.000Z' + const fixture = mysqlFixture(undefined, [ + { syncStateId: 1, userId: 1, created_at: when, updated_at: when, when, init } + ]) + try { + const assertRows = (rows: Awaited>) => { + expect(rows).toHaveLength(1) + expect(rows[0]).toEqual({ + syncStateId: 1, + userId: 1, + created_at: new Date(when), + updated_at: new Date(when), + when: new Date(when), + init: init === 1 + }) + } + assertRows(await fixture.storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 } })) + await fixture.storage.readSnapshot(async trx => { + assertRows(await fixture.storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 }, trx })) + }) + await fixture.db.transaction(async trx => { + assertRows(await fixture.storage.findSyncStates({ partial: { userId: 1, syncStateId: 1 }, trx })) + }) + const queries = fixture.events.filter(sql => sql.startsWith('SELECT')) + expect(queries).toHaveLength(3) + expect(queries.map(sql => sql.endsWith('FOR UPDATE'))).toEqual([false, false, true]) + } finally { + await fixture.db.destroy() + jest.restoreAllMocks() + } + } +) + test.each(exactReads)( 'MySQL snapshot %s uses a consistent read while writable transactions still lock', async (_name, read) => { diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts index 984bd108e..f17c0380d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/StorageKnex.retainedSnapshot.test.ts @@ -74,6 +74,7 @@ test('retains an already-pinned SQLite view across independent writes, idle peri view.read(trx => source .toDb(trx)<{ + userId: number txLabelId: number label: string isDeleted: boolean | number diff --git a/packages/wallet/wallet-toolbox/test/utils/mysqlReadSnapshotFixture.ts b/packages/wallet/wallet-toolbox/test/utils/mysqlReadSnapshotFixture.ts new file mode 100644 index 000000000..cdddb6243 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/utils/mysqlReadSnapshotFixture.ts @@ -0,0 +1,45 @@ +import { knex } from 'knex' +import { StorageKnex } from '../../src/storage/StorageKnex' +import { StorageProvider } from '../../src/storage/StorageProvider' + +export function mysqlReadSnapshotFixture(failAt?: string, selectedRows: readonly unknown[] = []) { + const db = knex({ client: 'mysql2' }) + const storage = new StorageKnex({ ...StorageProvider.createStorageBaseOptions('test'), knex: db }) + const events: string[] = [] + const failure = new Error('synthetic database failure') + const connection = { + destroy() { + events.push('CLOSE') + }, + query( + query: { sql: string }, + _bindings: unknown, + callback: (error: Error | null, rows?: unknown[], fields?: unknown[]) => void + ) { + const sql = query.sql.toUpperCase().replace(/;$/, '') + events.push(sql) + if (sql === failAt) return callback(failure) + // Exercise the actual installed Knex transaction builder against MySQL's + // comma-separated characteristic grammar. The combined options emitted + // invalid syntax before this regression was found with real MySQL 8.4. + if (sql.includes('REPEATABLE READ READ ONLY')) return callback(new Error('invalid MySQL syntax')) + callback(null, sql.startsWith('SELECT') ? structuredClone([...selectedRows]) : [], []) + } + } + const acquire = jest.spyOn(db.client, 'acquireConnection').mockResolvedValue(connection) + jest.spyOn(db.client, 'releaseConnection').mockImplementation(() => { + events.push('RELEASE') + return Promise.resolve() + }) + storage._settings = { + created_at: new Date(0), + updated_at: new Date(0), + storageIdentityKey: 'synthetic-storage', + storageName: 'synthetic MySQL', + chain: 'test', + dbtype: 'MySQL', + maxOutputScript: 1024 + } + jest.spyOn(storage, 'makeAvailable').mockResolvedValue(storage._settings) + return { storage, db, events, failure, acquire } +} diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 82cbe9573..01e116ada 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 47) + assert.equal(result.summary.propertySuites, 48) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 5) assert.equal(result.summary.propertyClassifiedPackages, 36) - assert.equal(result.summary.mutationTargets, 47) + assert.equal(result.summary.mutationTargets, 48) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) From 7266beca9d61170ae36554b34a4ee38528e3f4be Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 10:13:04 -0700 Subject: [PATCH 108/127] Isolate adaptive sync forecasts by table and actual page costs --- docs/guides/wallet-sync-reliability.md | 2 + docs/reference/package-api-migrations.md | 74 +++++----- governance/mutation-testing/policy.json | 10 ++ governance/mutation-testing/targets.mjs | 24 ++++ governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 15 +- packages/wallet/wallet-toolbox/README.md | 2 + packages/wallet/wallet-toolbox/package.json | 2 +- .../snapshot/SnapshotSyncSession.test.ts | 63 ++++++++- .../snapshot/runSnapshotSyncSession.ts | 4 +- .../sync/SyncPageBudget.property.test.ts | 76 ++++++++++ .../src/storage/sync/SyncPageBudget.test.ts | 94 +++++++++++++ .../src/storage/sync/SyncPageBudget.ts | 132 ++++++++++++++---- scripts/mutation-testing.test.mjs | 30 +++- scripts/test-governance.test.mjs | 4 +- 15 files changed, 459 insertions(+), 77 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.property.test.ts diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 28c527560..d276a7349 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -101,6 +101,8 @@ floor probes recovery. These bounds prevent the previous fixed-latency feedback loop from permanently collapsing page size, without treating a slow proof page as evidence that all later metadata is equally expensive. +Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. + ## Canonical proof recovery Assembled BEEF is checked before spending or broadcasting. An invalid root can diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 305a979fc..c1335f6ba 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index fe20695c5..61a85ed25 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -77,6 +77,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "wallet-adaptive-sync-budget", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.property.test.ts", + "risk": "critical", + "boundary": "Wallet adaptive sync page work and payload forecasts, caller limits and current-table transition isolation", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "wallet-snapshot-sync", "manifest": "packages/wallet/wallet-toolbox/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 40e5db2c4..eee1b6d1c 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -903,6 +903,30 @@ export function buildMutationTargets(repositoryRoot) { } ) }, + 'wallet-adaptive-sync-budget': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.property.test.ts', + mutate: ['src/storage/sync/SyncPageBudget.ts'], + additionalInputs: ['src/storage/snapshot/runSnapshotSyncSession.ts'], + ...jestTarget( + 'jest.config.cjs', + [ + '/src/storage/sync/SyncPageBudget.test.ts', + '/src/storage/sync/SyncPageBudget.property.test.ts', + '/src/storage/snapshot/SnapshotSyncSession.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + }, ...snapshotSyncMutationTargets(repositoryRoot), 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 7b1087840..5384b2e79 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation." + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 9cc9e67ed..56c847ee1 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -548,6 +548,19 @@ "Invalid source/schema or ownership evidence never silently adopts foreign DDL, and interrupted installation resumes only the persisted epoch and exact authorized next object." ] }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Wallet adaptive sync page work and payload forecasts, caller limits and current-table transition isolation", + "target": "Bounded generated table, fixed/marginal cost and payload transitions with independent caller/charge limits", + "invariants": [ + "Row and byte ceilings and the caller cursor remain unchanged and each request stays within the caller row bound.", + "Observed actual payload charges conservatively limit subsequent page requests without granting allocation or physical quota authority.", + "A known next-table transition discards previous workload forecasts before its first read; same-table observations persist.", + "Proof page ceilings, independent session state, minimum300 runs and original interrupt-as-failure controls remain." + ] + }, { "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts", "manifest": "packages/wallet/wallet-toolbox/package.json", @@ -594,7 +607,7 @@ "Exact page retries preserve immutable bytes and the independent hash-chain receipt without double charging.", "The authenticated profile cannot read or release another profile's archive.", "Capacity remains reserved through interrupted cleanup and is released exactly once after every page is deleted.", - "A complete SQL capture contains the selected profile’s original rows; cancellation removes all staged pages and reservations without activating wallet data." + "A complete SQL capture contains the selected profile\u2019s original rows; cancellation removes all staged pages and reservations without activating wallet data." ] }, { diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index c4a32143b..f089002a9 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -408,6 +408,8 @@ validation and 5.58 seconds with bounded concurrency, with the same 250 root and Timeouts remain possible during dependency outages; writes are never blindly replayed, and resumed sync rereads durable destination progress. +Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. + The adaptive page controller and optional validated-proof lookup add a small client bundle cost. The [artifact measurements and limits](./docs/sync-transfer.md#artifact-cost-requiring-review) include the combined upstream security fixes. These are explicit feature costs; diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 400e3b22d..c8e18e56e 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -55,7 +55,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts src/storage/sync/SyncPageBudget.property.test.ts --testPathIgnorePatterns=man.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts index fa173d894..daee59511 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncSession.test.ts @@ -371,13 +371,56 @@ test('progress timings separate read, preparation, queue and commit costs', asyn queueMs: 17, commitMs: 19 }) - expect(budget).toHaveBeenCalledWith(expect.any(Object), 43, 24) + expect(budget).toHaveBeenCalledWith(expect.any(Object), 43, 24, 0) } finally { budget.mockRestore() clock.mockRestore() } }) +test('actual returned payload charges bound the next request while every page commits in order', async () => { + const f = session() + const requested: Array<{ maxRows: number; maxBytes: number }> = [] + let remaining = 40 + let position = 7 + let durable = { ...f.checkpoint } + ;(f.view.readPage as jest.Mock).mockImplementation(async (_table, _cursor, limits) => { + requested.push({ ...limits }) + const count = Math.min(remaining, limits.maxRows, Math.floor(limits.maxBytes / 512)) + remaining -= count + position += count + return { + rows: Array.from({ length: count }, () => ({})), + payloadBytes: count * 512, + done: remaining === 0, + cursor: remaining === 0 ? undefined : { ...f.checkpoint.cursor, after: [position] } + } + }) + ;(f.destination.prepare as jest.Mock).mockImplementation(async (checkpoint, page) => { + const expected = { ...checkpoint } + return async () => { + expect(durable.sequence).toBe(expected.sequence) + durable = { + ...expected, + sequence: expected.sequence + 1, + tableIndex: expected.tableIndex + Number(page.done), + cursor: page.cursor, + done: page.done + } + return { checkpoint: durable, inserts: page.rows.length, updates: 0 } + } + }) + const result = await runSnapshotSyncSession(f.input, { maxItems: 1000, maxRoughSize: 8192 }) + expect(result).toMatchObject({ status: 'completed', pages: 3, inserts: 40, updates: 0 }) + expect(requested[0]).toEqual({ maxRows: 64, maxBytes: 8192 }) + expect(requested.slice(1)).toEqual([ + { maxRows: 16, maxBytes: 8192 }, + { maxRows: 16, maxBytes: 8192 } + ]) + expect(durable).toMatchObject({ sequence: 14, tableIndex: 12, done: true, cursor: undefined }) + expect(f.commit).toHaveBeenCalledTimes(4) +}) + test.each([1, 10000000])('the exact byte ceiling %s remains accepted', async maxRoughSize => { const f = session() expect(await runSnapshotSyncSession(f.input, { maxItems: 1, maxRoughSize })).toMatchObject({ @@ -389,3 +432,21 @@ test.each([1, 10000000])('the exact byte ceiling %s remains accepted', async max maxBytes: maxRoughSize }) }) + +test('identifies the current snapshot table before reading its first page', async () => { + const f = session() + const apply = jest.spyOn(SyncPageBudget.prototype, 'apply') + const beginTable = jest.spyOn(SyncPageBudget.prototype, 'beginTable') + try { + await runSnapshotSyncSession(f.input, { maxItems: 17, maxRoughSize: 8192 }) + expect(apply).toHaveBeenCalledTimes(1) + expect(beginTable).toHaveBeenCalledTimes(1) + expect(beginTable).toHaveBeenCalledWith('provenTxReqs') + expect(beginTable.mock.invocationCallOrder[0]).toBeLessThan(apply.mock.invocationCallOrder[0]) + expect(apply).toHaveBeenCalledWith(expect.objectContaining({ maxItems: 17, maxRoughSize: 8192 })) + expect(f.view.readPage).toHaveBeenCalledWith('provenTxReqs', f.checkpoint.cursor, { maxRows: 17, maxBytes: 8192 }) + } finally { + apply.mockRestore() + beginTable.mockRestore() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts index 86d51e939..c2f491d26 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts @@ -142,6 +142,7 @@ export async function runSnapshotSyncSession( if (cancelled()) return result const checkpoint = result.snapshotCheckpoint const table = snapshotSyncTables[checkpoint.tableIndex] + budget.beginTable(table) const prepared = await preparePage( session, checkpoint, @@ -179,7 +180,8 @@ export async function runSnapshotSyncSession( [table]: page.rows } as SyncChunk, readMs + prepareMs + commitMs, - readMs + prepareMs + readMs + prepareMs, + page.payloadBytes ) notify('committed', { readMs, prepareMs, queueMs: commitAt - queuedAt, commitMs }) if (cancelled()) return result diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.property.test.ts new file mode 100644 index 000000000..1c6357fdb --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.property.test.ts @@ -0,0 +1,76 @@ +import fc from 'fast-check' +import { SyncPageBudget } from './SyncPageBudget' +import type { RequestSyncChunkArgs, SyncChunk } from '../../sdk/WalletStorage.interfaces' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +test('generated table and payload changes preserve caller limits and charge-aware requests', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 1000 }), + fc.integer({ min: 4096, max: 1048576 }), + fc.array( + fc.record({ + rows: fc.integer({ min: 1, max: 512 }), + rowBytes: fc.integer({ min: 8, max: 4096 }), + fixedRead: fc.integer({ min: 0, max: 50000 }), + fixedCommit: fc.integer({ min: 0, max: 20000 }), + rowRead: fc.integer({ min: 0, max: 100 }), + rowCommit: fc.integer({ min: 0, max: 200 }), + proof: fc.boolean() + }), + { minLength: 1, maxLength: 6 } + ), + (maxItems, maxRoughSize, phases) => { + const original = { + identityKey: 'synthetic', + fromStorageIdentityKey: 'source', + toStorageIdentityKey: 'destination', + maxItems, + maxRoughSize, + offsets: [{ name: 'transaction', offset: 27 }] + } as RequestSyncChunkArgs + const snapshot = JSON.stringify(original) + const budget = new SyncPageBudget() + for (const [index, phase] of phases.entries()) { + const metadataTable = index % 2 === 0 ? 'transactions' : 'outputs' + const table = phase.proof ? 'provenTxs' : metadataTable + const byteRows = Math.floor(maxRoughSize / phase.rowBytes) + let remaining = phase.rows + while (remaining > 0) { + const request = budget.apply(original, table) + expect(request.maxItems).toBeGreaterThanOrEqual(1) + expect(request.maxItems).toBeLessThanOrEqual(maxItems) + expect(request.maxRoughSize).toBe(maxRoughSize) + expect(request.offsets).toBe(original.offsets) + const count = Math.min(request.maxItems, byteRows, remaining) + const page = { + userIdentityKey: original.identityKey, + fromStorageIdentityKey: original.fromStorageIdentityKey, + toStorageIdentityKey: original.toStorageIdentityKey, + [table]: Array.from({ length: count }, () => ({})) + } as SyncChunk + const read = phase.fixedRead + count * phase.rowRead + const commit = phase.fixedCommit + count * phase.rowCommit + budget.committed(page, read + commit, read, count * phase.rowBytes) + const next = budget.apply(original, table) + expect(next.maxItems).toBeLessThanOrEqual(byteRows) + if (phase.proof) expect(next.maxItems).toBeLessThanOrEqual(128) + remaining -= count + } + } + expect(JSON.stringify(original)).toBe(snapshot) + expect(new SyncPageBudget().apply(original).maxItems).toBe(Math.min(64, maxItems)) + } + ), + { interruptAfterTimeLimit: 150000, markInterruptAsFailure: true } + ) +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts index 6030ed029..160f0b37b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts @@ -96,3 +96,97 @@ test('does not reuse cheap metadata estimates for expensive proofs', () => { budget.committed(chunk(100, true), 50000, 1000) expect(budget.apply(args).maxItems).toBeLessThanOrEqual(10) }) + +test('does not carry a fitted fixed query cost into another metadata table', () => { + const budget = new SyncPageBudget() + for (let i = 0; i < 10; i++) { + const count = budget.apply(args).maxItems + budget.committed(chunk(count), 20000 + count * 2, 19000 + count) + } + expect(budget.apply(args).maxItems).toBe(1000) + const outputPage = { + ...chunk(100), + transactions: undefined, + outputs: Array.from({ length: 100 }, () => ({})) + } as SyncChunk + budget.committed(outputPage, 50000, 49000) + // The old table's 20-second fixed cost must not make this new work look cheap. + expect(budget.apply(args).maxItems).toBe(10) +}) + +test('keeps page size stable under modest latency noise and shrinks a real slowdown', () => { + const budget = new SyncPageBudget() + for (let i = 0; i < 10; i++) { + const count = budget.apply(args).maxItems + budget.committed(chunk(count, true), 16000 + count * 100, 15000 + count * 20) + } + const settled = budget.apply(args).maxItems + const limits: number[] = [] + for (let i = 0; i < 24; i++) { + const count = budget.apply(args).maxItems + limits.push(count) + budget.committed(chunk(count, true), 16000 + count * 100 + (i % 2 === 0 ? 100 : -100), 15000 + count * 20) + } + expect(limits.every(limit => limit === settled)).toBe(true) + budget.committed(chunk(settled, true), 16000 + settled * 1000, 15000 + settled * 20) + expect(budget.apply(args).maxItems).toBeLessThan(settled / 2) +}) + +test('uses observed page bytes conservatively without changing caller limits or cursors', () => { + const budget = new SyncPageBudget() + budget.committed(chunk(64), 100, 50, 64 * 250000) + const original = JSON.stringify(args) + expect(budget.apply(args)).toEqual({ ...args, maxItems: 8 }) + expect(budget.apply({ ...args, maxItems: 3 }).maxItems).toBe(3) + budget.committed(chunk(8), 100, 50, 8 * 500000) + expect(budget.apply(args).maxItems).toBe(4) + budget.committed(chunk(4), 100, 50, 4 * 1000) + expect(budget.apply(args).maxItems).toBe(5) + expect(JSON.stringify(args)).toBe(original) +}) + +test('ignores invalid byte measurements and forgets bytes on a table transition', () => { + const budget = new SyncPageBudget() + for (const bytes of [NaN, Infinity, -1, 0, 1.5]) budget.committed(chunk(64), 100, 50, bytes) + expect(budget.apply(args).maxItems).toBe(1000) + budget.committed(chunk(1), 100, 50, 4000000) + expect(budget.apply(args).maxItems).toBe(1) + budget.committed(chunk(64, true), 100, 50) + expect(budget.apply(args).maxItems).toBe(128) +}) + +test('workload identity is independent of property order for a mixed legacy chunk', () => { + const budget = new SyncPageBudget() + const rows = Array.from({ length: 16 }, () => ({})) + const mixed = { ...chunk(0), transactions: rows, outputs: rows } as SyncChunk + for (let i = 0; i < 10; i++) budget.committed(mixed, 100, 50, 32 * 250000) + const expected = budget.apply(args) + budget.committed({ ...chunk(0), outputs: rows, transactions: rows } as SyncChunk, 100, 50) + expect(budget.apply(args)).toEqual(expected) +}) + +test('uses the known next table before fetching and preserves observations within that table', () => { + const budget = new SyncPageBudget() + budget.committed(chunk(1, true), 20000, 19000, 4000000) + expect(budget.apply(args).maxItems).toBe(1) + expect(budget.apply(args, 'txLabels').maxItems).toBe(64) + expect(budget.apply(args, 'txLabels').maxItems).toBe(64) + const labels = { ...chunk(0), txLabels: Array.from({ length: 64 }, () => ({})) } as SyncChunk + budget.committed(labels, 100, 50, 64 * 128) + expect(budget.apply(args, 'txLabels').maxItems).toBe(128) + expect(budget.apply(args, 'txLabels').maxItems).toBe(128) +}) + +test('known table transitions preserve smaller caller limits and original byte and cursor values', () => { + const budget = new SyncPageBudget() + budget.committed(chunk(64), 100, 50, 64 * 250000) + expect(budget.apply(args).maxItems).toBe(8) + const limits = { ...args, maxItems: 3, maxRoughSize: 1234 } + const original = JSON.stringify(limits) + const request = budget.apply(limits, 'outputs') + expect(request).toEqual(limits) + expect(request.offsets).toBe(limits.offsets) + expect(JSON.stringify(limits)).toBe(original) + expect(budget.apply(args, 'outputs').maxItems).toBe(64) + expect(budget.apply(args, 'provenTxs').maxItems).toBe(64) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts index 78d29ae2b..978f0bcb2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts @@ -1,4 +1,5 @@ import type { RequestSyncChunkArgs, SyncChunk } from '../../sdk/WalletStorage.interfaces' +import type { WalletSnapshotTable } from '../snapshot/WalletReadSnapshot' interface PageCost { records: number @@ -6,12 +7,25 @@ interface PageCost { commitMs: number } +function pageWorkload(chunk: SyncChunk): { records: number; proofs: boolean; workload: string } { + let records = 0 + const tables: string[] = [] + for (const [name, value] of Object.entries(chunk)) { + if (Array.isArray(value) && value.length > 0) { + records += value.length + tables.push(name) + } + } + tables.sort((first, second) => first.localeCompare(second)) + return { records, proofs: (chunk.provenTxs?.length ?? 0) > 0, workload: tables.join(',') } +} + /** Fit fixed overhead separately from marginal work, using bounded recent history. */ function marginalCost( samples: PageCost[], phase: 'readMs' | 'commitMs', previousFixedMs: number -): { perRecordMs: number; fixedMs: number } { +): { perRecordMs: number; fixedMs: number; fitted: boolean } { let totalRecords = 0 let totalMs = 0 for (const sample of samples) { @@ -30,50 +44,115 @@ function marginalCost( // Until page sizes differ there is no evidence that any cost is fixed. if (variance === 0) { const fixedMs = Math.min(previousFixedMs, averageMs) - return { perRecordMs: (averageMs - fixedMs) / averageRecords, fixedMs } + return { perRecordMs: (averageMs - fixedMs) / averageRecords, fixedMs, fitted: false } } const slope = Math.max(0, covariance / variance) const fixedMs = Math.max(0, averageMs - slope * averageRecords) const latest = samples.at(-1)! // React to a newly expensive page immediately, even when the rolling fit // still contains cheap pages. Never subtract more than its observed cost. - return { perRecordMs: Math.max(slope, (latest[phase] - fixedMs) / latest.records), fixedMs } + return { perRecordMs: Math.max(slope, (latest[phase] - fixedMs) / latest.records), fixedMs, fitted: true } } /** Per-copy work budget. Bytes alone cannot bound network-backed proof checks. */ export class SyncPageBudget { private maxItems = 64 private samples: PageCost[] = [] - private proofs?: boolean + private workload?: string private fixedReadMs = 0 private fixedCommitMs = 0 private pagesSinceProbe = 0 + private smoothedCost?: number + private fitted = false + private bytesPerRecord?: number - apply(args: RequestSyncChunkArgs): RequestSyncChunkArgs { - return { ...args, maxItems: Math.min(args.maxItems, this.maxItems) } + /** Initialize a known table before its first asynchronous read. */ + beginTable(table: WalletSnapshotTable): void { + if (this.beginWorkload(table)) this.maxItems = 64 + } + + apply(args: RequestSyncChunkArgs, nextTable?: WalletSnapshotTable): RequestSyncChunkArgs { + // A snapshot caller knows the next table before fetching its first page. + // Start it conservatively instead of carrying a previous table's floor + // or payload estimate into a different workload. Legacy callers can still + // discover transitions from their committed chunks. + if (nextTable !== undefined) this.beginTable(nextTable) + // This estimate only chooses a request size. The provider still enforces + // the actual row/byte limits, including refusal of an oversized single row. + const byteItems = + this.bytesPerRecord === undefined + ? args.maxItems + : Math.max(1, Math.floor(args.maxRoughSize / this.bytesPerRecord)) + return { ...args, maxItems: Math.min(args.maxItems, this.maxItems, byteItems) } + } + + private beginWorkload(workload: string): boolean { + if (workload === this.workload) return false + this.samples = [] + this.fixedReadMs = 0 + this.fixedCommitMs = 0 + this.pagesSinceProbe = 0 + this.smoothedCost = undefined + this.fitted = false + this.bytesPerRecord = undefined + this.workload = workload + return true + } + + private observePayload(records: number, payloadBytes: number | undefined): void { + if (payloadBytes === undefined || !Number.isSafeInteger(payloadBytes) || payloadBytes <= 0) return + const currentBytes = payloadBytes / records + // React to larger payloads immediately; recover gradually when they shrink + // so an unusually small page cannot prompt an abrupt large fetch. + this.bytesPerRecord = + this.bytesPerRecord === undefined || currentBytes >= this.bytesPerRecord + ? currentBytes + : this.bytesPerRecord * 0.75 + currentBytes * 0.25 + } + + private observeCost(perRecordMs: number, fitted: boolean): number { + // The first varying-size fit separates previously unknown fixed latency. + // Do not smooth that discovery with the initial, biased per-row estimate. + // Subsequent recovery is smoothed; expensive new work shrinks immediately. + this.smoothedCost = + this.smoothedCost === undefined || (fitted && !this.fitted) || perRecordMs >= this.smoothedCost + ? perRecordMs + : this.smoothedCost * 0.75 + perRecordMs * 0.25 + this.fitted ||= fitted + return this.smoothedCost + } + + private chooseLimit(perRecordMs: number, ceiling: number): void { + const suggested = Math.floor(5000 / Math.max(0.001, perRecordMs)) + let next = Math.max(1, Math.min(ceiling, this.maxItems * 2, suggested)) + // Avoid changing the request for small variations around the work target. + // Reaching the original ceiling must still work; cheap pages cannot stick + // just below it. A substantial new slowdown always shrinks immediately. + if (next !== ceiling && next >= this.maxItems * 0.8 && next <= this.maxItems * 1.2) + next = Math.min(ceiling, this.maxItems) + this.pagesSinceProbe++ + // At the one-record floor there is no size variation from which to learn + // fixed latency. A bounded two-record probe prevents permanent collapse. + // A genuinely expensive proof returns to one on the next measurement. + if (next === 1 && this.maxItems === 1 && this.pagesSinceProbe >= 4) next = 2 + if (next > this.maxItems) this.pagesSinceProbe = 0 + this.maxItems = next } /** * `elapsedMs` includes read and commit; an optional read measurement keeps - * network overhead separate from destination work. Old callers remain valid. + * network overhead separate from destination work. `payloadBytes`, when + * available, is the provider's actual page charge. Old callers remain valid. */ - committed(chunk: SyncChunk, elapsedMs: number, readMs = 0): void { + committed(chunk: SyncChunk, elapsedMs: number, readMs = 0, payloadBytes?: number): void { if (!Number.isFinite(elapsedMs) || elapsedMs < 0 || !Number.isFinite(readMs) || readMs < 0 || readMs > elapsedMs) return - let records = 0 - for (const value of Object.values(chunk)) { - if (Array.isArray(value)) records += value.length - } + const { records, proofs, workload } = pageWorkload(chunk) if (records === 0) return - const proofs = (chunk.provenTxs?.length ?? 0) > 0 - // Metadata throughput does not predict network-backed proof checks. - if (proofs !== this.proofs) { - this.samples = [] - this.fixedReadMs = 0 - this.fixedCommitMs = 0 - this.pagesSinceProbe = 0 - } - this.proofs = proofs + // Table transitions can change query, payload and proof costs. A stable + // sorted key also handles legacy chunks containing more than one table. + this.beginWorkload(workload) + this.observePayload(records, payloadBytes) this.samples.push({ records, readMs, commitMs: elapsedMs - readMs }) if (this.samples.length > 6) this.samples.shift() const ceiling = proofs ? 128 : 1000 @@ -81,15 +160,6 @@ export class SyncPageBudget { const commit = marginalCost(this.samples, 'commitMs', this.fixedCommitMs) this.fixedReadMs = read.fixedMs this.fixedCommitMs = commit.fixedMs - const perRecordMs = read.perRecordMs + commit.perRecordMs - const suggested = Math.floor(5000 / Math.max(0.001, perRecordMs)) - let next = Math.max(1, Math.min(ceiling, this.maxItems * 2, suggested)) - this.pagesSinceProbe++ - // At the one-record floor there is no size variation from which to learn - // fixed latency. A bounded two-record probe prevents permanent collapse. - // A genuinely expensive proof returns to one on the next measurement. - if (next === 1 && this.maxItems === 1 && this.pagesSinceProbe >= 4) next = 2 - if (next > this.maxItems) this.pagesSinceProbe = 0 - this.maxItems = next + this.chooseLimit(this.observeCost(read.perRecordMs + commit.perRecordMs, read.fitted && commit.fitted), ceiling) } } diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 6b29999dd..0269355ad 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -214,7 +214,7 @@ test('additional package-relative fixture inputs select their target without rep ) const canonical = buildMutationTargets(REPOSITORY_ROOT) - assert.equal(Object.keys(canonical).length, 48) + assert.equal(Object.keys(canonical).length, 49) assert.deepEqual(canonical['wallet-retained-snapshot'].additionalInputs, [ 'test/utils/snapshotRelationFixtures.ts', 'test/utils/snapshotCertificateFixtures.ts', @@ -351,6 +351,34 @@ test('every inherited snapshot-sync target keeps the complete journal tests for } }) +test('adaptive paging owns its complete controller without removing the existing snapshot caller target', () => { + const targets = buildMutationTargets(REPOSITORY_ROOT) + const target = targets['wallet-adaptive-sync-budget'] + assert.deepEqual(target.mutate, ['src/storage/sync/SyncPageBudget.ts']) + assert.deepEqual(target.additionalInputs, ['src/storage/snapshot/runSnapshotSyncSession.ts']) + assert.deepEqual(target.runnerOptions.jest.config.testMatch, [ + '/src/storage/sync/SyncPageBudget.test.ts', + '/src/storage/sync/SyncPageBudget.property.test.ts', + '/src/storage/snapshot/SnapshotSyncSession.test.ts' + ]) + assert.ok( + targets['wallet-snapshot-sync'].mutate.includes( + 'src/storage/snapshot/runSnapshotSyncSession.ts' + ) + ) + assert.deepEqual( + selectAffectedMutationTargets(targets, [ + 'packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts' + ]), + ['wallet-adaptive-sync-budget'] + ) + const affected = selectAffectedMutationTargets(targets, [ + 'packages/wallet/wallet-toolbox/src/storage/snapshot/runSnapshotSyncSession.ts' + ]) + assert.ok(affected.includes('wallet-adaptive-sync-budget')) + assert.ok(affected.includes('wallet-snapshot-sync')) +}) + test('snapshot sync owns every inherited manager region and complete primary selection', () => { const target = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-sync'] const file = 'src/storage/WalletStorageManager.ts' diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 01e116ada..dd7e6983b 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 48) + assert.equal(result.summary.propertySuites, 49) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 5) assert.equal(result.summary.propertyClassifiedPackages, 36) - assert.equal(result.summary.mutationTargets, 48) + assert.equal(result.summary.mutationTargets, 49) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) From 74179d0a9dd31165b9aac521e6906263b90281fe Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 10:59:04 -0700 Subject: [PATCH 109/127] React immediately to expensive equal-size sync pages --- docs/guides/wallet-sync-reliability.md | 2 +- docs/reference/package-api-migrations.md | 2 +- governance/package-release-notes.json | 2 +- packages/wallet/wallet-toolbox/README.md | 2 +- .../src/storage/sync/SyncPageBudget.test.ts | 109 +++++++++++++++++- .../src/storage/sync/SyncPageBudget.ts | 8 +- 6 files changed, 118 insertions(+), 7 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index d276a7349..4beb0c231 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -101,7 +101,7 @@ floor probes recovery. These bounds prevent the previous fixed-latency feedback loop from permanently collapsing page size, without treating a slow proof page as evidence that all later metadata is equally expensive. -Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. +Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. A newly expensive page immediately shrinks the next work budget even when recent pages have equal row counts; sustained recovery remains smoothed to avoid request-size oscillation. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. ## Canonical proof recovery diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index c1335f6ba..5f99134ef 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -514,7 +514,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. - Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 5384b2e79..093d9f02d 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,7 +210,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges.", + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts.", "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required." }, { diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index f089002a9..19bf49d04 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -408,7 +408,7 @@ validation and 5.58 seconds with bounded concurrency, with the same 250 root and Timeouts remain possible during dependency outages; writes are never blindly replayed, and resumed sync rereads durable destination progress. -Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. +Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. A newly expensive page immediately shrinks the next work budget even when recent pages have equal row counts; sustained recovery remains smoothed to avoid request-size oscillation. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. The adaptive page controller and optional validated-proof lookup add a small client bundle cost. The [artifact measurements and limits](./docs/sync-transfer.md#artifact-cost-requiring-review) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts index 160f0b37b..7369fc74d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.test.ts @@ -161,7 +161,17 @@ test('workload identity is independent of property order for a mixed legacy chun const mixed = { ...chunk(0), transactions: rows, outputs: rows } as SyncChunk for (let i = 0; i < 10; i++) budget.committed(mixed, 100, 50, 32 * 250000) const expected = budget.apply(args) - budget.committed({ ...chunk(0), outputs: rows, transactions: rows } as SyncChunk, 100, 50) + budget.committed( + { + outputs: rows, + transactions: rows, + fromStorageIdentityKey: 'from', + toStorageIdentityKey: 'to', + userIdentityKey: 'user' + } as SyncChunk, + 100, + 50 + ) expect(budget.apply(args)).toEqual(expected) }) @@ -190,3 +200,100 @@ test('known table transitions preserve smaller caller limits and original byte a expect(budget.apply(args, 'outputs').maxItems).toBe(64) expect(budget.apply(args, 'provenTxs').maxItems).toBe(64) }) + +test.each([0, 0.4, 1])('shrinks immediately after equal-size pages become expensive (read share=%s)', readShare => { + const budget = new SyncPageBudget() + const capped = { ...args, maxItems: 64 } + for (let i = 0; i < 8; i++) { + budget.committed(chunk(64), 100, 100 * readShare) + expect(budget.apply(capped).maxItems).toBe(64) + } + // The caller's row cap has kept every observed page the same size. A new + // 200-ms-per-record cost must constrain the very next five-second request. + budget.committed(chunk(64), 12800, 12800 * readShare) + expect(budget.apply(capped).maxItems).toBe(25) +}) + +test('retains the metadata ceiling when the caller allows more rows', () => { + const budget = new SyncPageBudget() + const wide = { ...args, maxItems: 5000 } + for (let i = 0; i < 8; i++) budget.committed(chunk(budget.apply(wide).maxItems), 0) + expect(budget.apply(wide).maxItems).toBe(1000) +}) + +test('probes the single-row floor only periodically and returns after expensive proofs', () => { + const budget = new SyncPageBudget() + budget.committed(chunk(64, true), 640000, 320000) + const limits: number[] = [] + for (let i = 0; i < 12; i++) { + const count = budget.apply(args).maxItems + limits.push(count) + budget.committed(chunk(count, true), count * 10000, count * 5000) + } + expect(limits).toEqual([1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 2]) +}) + +test.each([ + [125, 50], + [125.1, 39] +])('holds a 20-percent boundary but shrinks beyond it (cost=%s)', (cost, expected) => { + const budget = new SyncPageBudget() + budget.committed(chunk(64), 6400) + for (let i = 0; i < 8; i++) budget.committed(chunk(50), 5000) + expect(budget.apply(args).maxItems).toBe(50) + budget.committed(chunk(50), 50 * cost) + expect(budget.apply(args).maxItems).toBe(expected) +}) + +test('recovers gradually from large payloads while preserving the byte ceiling', () => { + const budget = new SyncPageBudget() + budget.committed(chunk(64), 0, 0, 64 * 500000) + const limits = [budget.apply(args).maxItems] + for (let i = 0; i < 3; i++) { + budget.committed(chunk(64), 0, 0, 64 * 100000) + limits.push(budget.apply(args).maxItems) + } + expect(limits).toEqual([4, 5, 6, 7]) +}) + +test('invalid timing and byte observations cannot discard an established workload budget', () => { + const budget = new SyncPageBudget() + budget.committed(chunk(64), 100, 50, 64 * 500000) + const expected = budget.apply(args) + for (const readMs of [NaN, Infinity, -1, 101]) { + budget.committed(chunk(64, true), 100, readMs, 64) + expect(budget.apply(args)).toEqual(expected) + } + for (const bytes of [NaN, Infinity, -1, 0, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + budget.committed(chunk(64), 100, 50, bytes) + expect(budget.apply(args)).toEqual(expected) + } + budget.committed(chunk(0, true), 0, 0, 1) + expect(budget.apply(args)).toEqual(expected) +}) + +test('forgets an old slowdown after sustained cheap equal-size pages', () => { + const budget = new SyncPageBudget() + const capped = { ...args, maxItems: 64 } + for (let i = 0; i < 6; i++) budget.committed(chunk(64), 12800, 6400) + expect(budget.apply(capped).maxItems).toBe(25) + for (let i = 0; i < 32; i++) budget.committed(chunk(64), 640, 320) + expect(budget.apply(capped).maxItems).toBe(64) + // An ever-growing history would still let the original slow pages dominate. + // Sustained 10-ms records must recover most of the five-second work budget. + expect(budget.apply(args).maxItems).toBeGreaterThanOrEqual(400) + expect(budget.apply(args).maxItems).toBeLessThanOrEqual(500) +}) + +test('holds modest cost recovery inside hysteresis and grows after a sustained improvement', () => { + const budget = new SyncPageBudget() + budget.committed(chunk(64), 6400) + for (let i = 0; i < 8; i++) budget.committed(chunk(50), 5000) + for (let i = 0; i < 8; i++) { + budget.committed(chunk(50), 4000) + expect(budget.apply(args).maxItems).toBe(50) + } + for (let i = 0; i < 32; i++) budget.committed(chunk(50), 4000) + expect(budget.apply(args).maxItems).toBeGreaterThan(60) + expect(budget.apply(args).maxItems).toBeLessThanOrEqual(62) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts index 978f0bcb2..78255d59b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts +++ b/packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.ts @@ -42,13 +42,17 @@ function marginalCost( covariance += delta * (sample[phase] - averageMs) } // Until page sizes differ there is no evidence that any cost is fixed. + const latest = samples.at(-1)! if (variance === 0) { const fixedMs = Math.min(previousFixedMs, averageMs) - return { perRecordMs: (averageMs - fixedMs) / averageRecords, fixedMs, fitted: false } + return { + perRecordMs: Math.max((averageMs - fixedMs) / averageRecords, (latest[phase] - fixedMs) / latest.records), + fixedMs, + fitted: false + } } const slope = Math.max(0, covariance / variance) const fixedMs = Math.max(0, averageMs - slope * averageRecords) - const latest = samples.at(-1)! // React to a newly expensive page immediately, even when the rolling fit // still contains cheap pages. Never subtract more than its observed cost. return { perRecordMs: Math.max(slope, (latest[phase] - fixedMs) / latest.records), fixedMs, fitted: true } From b4bd5023cf4a8b69d9aa3b8cd574933a3646f6a6 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 12:17:19 -0700 Subject: [PATCH 110/127] feat(wallet): add bounded portable streaming components --- docs/guides/wallet-data-portability.md | 36 +- docs/guides/wallet-sync-reliability.md | 16 +- docs/reference/package-api-migrations.md | 90 +-- governance/mutation-testing/policy.json | 80 +++ governance/mutation-testing/targets.mjs | 55 ++ governance/package-release-notes.json | 4 +- governance/test-quality/policy.json | 88 +++ packages/wallet/wallet-toolbox/README.md | 9 +- packages/wallet/wallet-toolbox/package.json | 16 +- .../portable/Brc38JsonStream.property.test.ts | 129 ++++ .../storage/portable/Brc38JsonStream.test.ts | 526 ++++++++++++++++ .../src/storage/portable/Brc38JsonStream.ts | 459 ++++++++++++++ .../portable/Brc38JsonStreamFixture.ts | 32 + .../storage/portable/Brc38KnexSource.test.ts | 439 +++++++++++++ .../src/storage/portable/Brc38KnexSource.ts | 336 ++++++++++ .../storage/portable/Brc38PackedRow.test.ts | 278 +++++++++ .../src/storage/portable/Brc38PackedRow.ts | 244 ++++++++ .../src/storage/portable/Brc38Stream.test.ts | 327 ++++++++++ .../src/storage/portable/Brc38Stream.ts | 225 +++++++ .../portable/Brc39Frame.property.test.ts | 216 +++++++ .../src/storage/portable/Brc39Frame.test.ts | 168 +++++ .../src/storage/portable/Brc39Frame.ts | 193 ++++++ .../portable/Brc39PrivateFileNode.test.ts | 525 ++++++++++++++++ .../storage/portable/Brc39PrivateFileNode.ts | 280 +++++++++ .../storage/portable/Brc39StreamNode.test.ts | 584 ++++++++++++++++++ .../src/storage/portable/Brc39StreamNode.ts | 302 +++++++++ .../CanonicalPortableChunks.property.test.ts | 57 ++ .../portable/CanonicalPortableChunks.test.ts | 145 +++++ .../portable/CanonicalPortableChunks.ts | 162 +++++ .../src/storage/portable/node.ts | 5 + .../src/storage/portable/stream.ts | 6 + .../test/consumer/portableStreams.ts | 62 ++ .../test/consumer/tsconfig.json | 2 +- scripts/mutation-testing.test.mjs | 44 +- scripts/test-governance.test.mjs | 4 +- 35 files changed, 6072 insertions(+), 72 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStreamFixture.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/node.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/stream.ts create mode 100644 packages/wallet/wallet-toolbox/test/consumer/portableStreams.ts diff --git a/docs/guides/wallet-data-portability.md b/docs/guides/wallet-data-portability.md index 7de935aee..68986ebe4 100644 --- a/docs/guides/wallet-data-portability.md +++ b/docs/guides/wallet-data-portability.md @@ -3,8 +3,8 @@ id: wallet-data-portability title: 'BRC-38/39 Wallet Data Portability' kind: guide version: '1.0.0' -last_updated: '2026-09-30' -last_verified: '2026-09-30' +last_updated: '2026-10-04' +last_verified: '2026-10-04' review_cadence_days: 30 status: stable tags: [wallet, backup, interoperability, brc38, brc39] @@ -66,12 +66,24 @@ IndexedDB and remote clients do not gain this capability. The existing export helpers continue to use their scoped capture path; see [retained SQL view limits](wallet-sync-reliability.md#retained-local-sql-read-views-unpublished-candidate). -This is an intermediate source candidate, not a released streaming export API. -The materialized helpers below still allocate the full document/file. Remote -snapshot handles, bounded immutable paging, streaming files, staged recovery and -the push/backup scheduling changes remain required parts of the +This is an unpublished source candidate. The legacy materialized helpers below +still allocate the full document/file. The optional streaming entries described +below provide component limits; remote snapshot handles, durable staged recovery, +platform adapters and push/backup scheduling remain required parts of the [full implementation program](https://github.com/bsv-blockchain/ts-stack/blob/codex/wallet-sync-interop-reliability/specs/wallet/sync-portability-program.md). +### Unpublished bounded streaming entries + +The 2.15 candidate adds `@bsv/wallet-toolbox/portable` for bounded canonical value/row projection, the all-thirteen-table BRC-38 encoder and private JSON staging reader, and BRC-39 framing. `@bsv/wallet-toolbox/portable/node` additionally provides `openBrc38KnexSource`, native AES-GCM encryption/decryption and `createBrc39NodeFileQuarantine`. The existing materialized helpers and browser/mobile roots retain their contracts. + +Supply explicit row, page, metadata, chunk, archive, password and KDF ceilings. `openBrc38KnexSource` requires a dedicated compatible SQLite/MySQL reader provider and keeps source settings, identity, sync state and all thirteen table streams in one retained read view. A slow consumer occupies that provider until cleanup; use an independent foreground provider. The built-in SQL source validates closure in that same view before and after complete traversal. Host-defined sources must implement their own independent complete semantic/provenance validation and awaited cleanup. + +Encryption emits the existing WDAT envelope with canonical Argon2id defaults (7 iterations, 131072 KiB, parallelism 1), fresh 32-byte salt/nonce, NFC password bytes and a 16-byte GCM tag. New exports refuse weaker strength. Decryption admits valid legacy parameter values only within caller-selected work ceilings and supported native nonce lengths; the materialized codec remains available with its existing input contract. Progress and cancellation occur between owned operations; an already running KDF or file/database operation settles before cleanup. Source validation and physical close must succeed before the final encryption tag. Output remains private until the host completes its durable save transaction. + +Decryption writes only into isolated quarantine. GCM authentication must precede strict UTF-8 and complete BRC-38 semantic validation. `createBrc39NodeFileQuarantine` uses a caller-owned trusted parent, private 0700/0600 files, bounded serial reads/writes, fsync, content verification and explicit awaited `discard()`. Its `withAuthenticatedChunks` callback is available after validation and closes its reader even on early return. Always discard after the host operation settles. It does not import, activate a profile or provide a durable recovery transaction. `readBrc38JsonStream` likewise accepts authenticated plaintext and awaits one private staging callback at a time; its host validator must check every staged row, unique key, relation, identity, network and original provenance before a result can be used. + +These entry points bound each component's admitted work and buffers. They do not establish native allocator/RSS/IPC bounds, hard database/WAL/directory quotas, durable occupied-target restore, replicated remote export destinations, or physical mobile qualification. Those remain mandatory in the full #544 program. No pending intermediate API is a released production guarantee. + ## Coverage and limits The implementation exports one `user`, its `sourceStorage` metadata and 13 @@ -227,12 +239,7 @@ identity/network, obtain the user's confirmation of the target and mode, then invoke import. Do not trust an archive's identity as the selected profile. ```ts -import { - decryptBRC39, - importBRC38, - type BRC38WalletData, - type StorageProvider -} from '@bsv/wallet-toolbox' +import { decryptBRC39, importBRC38, type BRC38WalletData, type StorageProvider } from '@bsv/wallet-toolbox' export async function previewWalletDataFile( bytes: Uint8Array, @@ -251,10 +258,7 @@ export async function previewWalletDataFile( } // Call only after profile/target confirmation. Do not mutate the preview. -export async function restoreConfirmedWalletData( - emptyTarget: StorageProvider, - document: BRC38WalletData -) { +export async function restoreConfirmedWalletData(emptyTarget: StorageProvider, document: BRC38WalletData) { return await importBRC38(emptyTarget, document, { mode: 'restore' }) } ``` diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index 4beb0c231..d97f7c089 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -3,8 +3,8 @@ id: wallet-sync-reliability title: 'Resumable wallet synchronization and proof recovery' kind: guide version: '1.0.0' -last_updated: '2026-09-30' -last_verified: '2026-09-30' +last_updated: '2026-10-04' +last_verified: '2026-10-04' review_cadence_days: 30 status: beta tags: [wallet, sync, storage, performance] @@ -21,6 +21,18 @@ exclusive path unless their local provider explicitly supports the required capability. Existing `syncFromReader`, `syncToWriter` and their result contracts remain available. +## Optional portable streaming components + +The candidate adds `@bsv/wallet-toolbox/portable` for bounded BRC-38 codecs, +private JSON staging and BRC-39 framing. The `/portable/node` entry adds a +dedicated coherent SQL source, native encryption/decryption and private file +quarantine. The legacy materialized APIs retain their contracts. Read the +[portable streaming contract](wallet-data-portability.md#unpublished-bounded-streaming-entries) +for explicit limits, semantic validation and cleanup requirements. These +components do not advertise incremental synchronization or activate an imported +profile. Hard database/WAL/directory quotas, total native memory and IPC bounds, +durable import recovery and full issue #544 qualification remain required. + ## Consumer contract ```ts diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 5f99134ef..524e7ccb5 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,15 +514,17 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. - -| Public subpath | Runtime target(s) | Declaration target(s) | -| ---------------- | ---------------------------------------------------- | -------------------------- | -| `.` | `./out/src/index.js`
`./out/src/index.js` | `./out/src/index.d.ts` | -| `./out/src/sdk` | `./out/src/sdk/index.js`
`./out/src/sdk/index.js` | `./out/src/sdk/index.d.ts` | -| `./out/src/*` | `./out/src/*.js` | `./out/src/*.d.ts` | -| `./package.json` | `./package.json` | — | +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. + +| Public subpath | Runtime target(s) | Declaration target(s) | +| ----------------- | -------------------------------------------------------------------------------- | ---------------------------------------- | +| `.` | `./out/src/index.js`
`./out/src/index.js` | `./out/src/index.d.ts` | +| `./out/src/sdk` | `./out/src/sdk/index.js`
`./out/src/sdk/index.js` | `./out/src/sdk/index.d.ts` | +| `./out/src/*` | `./out/src/*.js` | `./out/src/*.d.ts` | +| `./package.json` | `./package.json` | — | +| `./portable` | `./out/src/storage/portable/stream.js`
`./out/src/storage/portable/stream.js` | `./out/src/storage/portable/stream.d.ts` | +| `./portable/node` | `./out/src/storage/portable/node.js`
`./out/src/storage/portable/node.js` | `./out/src/storage/portable/node.d.ts` | ## @bsv/wallet-toolbox-client diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 61a85ed25..154859b63 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -506,6 +506,86 @@ "minimumScore": 90, "maximumNoCoverage": 0, "maximumInvalid": 0 + }, + { + "id": "wallet-portable-canonical-chunks", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts", + "risk": "critical", + "boundary": "Allocation-bounded canonical RFC8785 portable serialization and detached source ownership", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-portable-packed-row", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.test.ts", + "risk": "critical", + "boundary": "Bounded packed-row binary, date and historical JSON projection preserving exact legacy portable semantics", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-portable-source-stream", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts", + "risk": "critical", + "boundary": "Complete thirteen-table canonical stream framing, original source validation and awaited cleanup before completion", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-portable-knex-source", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts", + "risk": "critical", + "boundary": "Coherent retained SQLite/MySQL thirteen-table keyset sources, bounded payload admission and profile closure", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-portable-json-stream", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.property.test.ts", + "risk": "critical", + "boundary": "Bounded strict UTF8 and complete BRC38 document framing into private staging with semantic completion and cleanup", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-portable-brc39-frame", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.property.test.ts", + "risk": "critical", + "boundary": "Bounded standard WDAT envelope framing and explicit KDF/file admission preserving complete ciphertext and tag", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-portable-brc39-node", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts", + "risk": "critical", + "boundary": "Node BRC39 native AEAD, canonical Argon2id/NFC, owned key/password cleanup and private staging backpressure", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-portable-private-file", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts", + "risk": "critical", + "boundary": "Node private authenticated-file quarantine, bounded serial I/O, complete verification and physical cleanup", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 } ] } diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index eee1b6d1c..fb1152a9d 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -45,6 +45,60 @@ function jestTarget( } } +function portableStreamMutationTargets(repositoryRoot) { + const definitions = [ + [ + 'wallet-portable-canonical-chunks', + 'CanonicalPortableChunks', + 'CanonicalPortableChunks.property.test.ts' + ], + ['wallet-portable-packed-row', 'Brc38PackedRow', 'Brc38PackedRow.test.ts'], + ['wallet-portable-source-stream', 'Brc38Stream', 'Brc38Stream.test.ts'], + ['wallet-portable-knex-source', 'Brc38KnexSource', 'Brc38KnexSource.test.ts'], + ['wallet-portable-json-stream', 'Brc38JsonStream', 'Brc38JsonStream.property.test.ts'], + ['wallet-portable-brc39-frame', 'Brc39Frame', 'Brc39Frame.property.test.ts'], + ['wallet-portable-brc39-node', 'Brc39StreamNode', 'Brc39StreamNode.test.ts'], + ['wallet-portable-private-file', 'Brc39PrivateFileNode', 'Brc39PrivateFileNode.test.ts'] + ] + const testMatch = [ + '/src/storage/portable/Brc38JsonStream.property.test.ts', + '/src/storage/portable/Brc38JsonStream.test.ts', + '/src/storage/portable/Brc38KnexSource.test.ts', + '/src/storage/portable/Brc38PackedRow.test.ts', + '/src/storage/portable/Brc38Stream.test.ts', + '/src/storage/portable/Brc39Frame.property.test.ts', + '/src/storage/portable/Brc39Frame.test.ts', + '/src/storage/portable/Brc39PrivateFileNode.test.ts', + '/src/storage/portable/Brc39StreamNode.test.ts', + '/src/storage/portable/CanonicalPortableChunks.property.test.ts', + '/src/storage/portable/CanonicalPortableChunks.test.ts' + ] + return Object.fromEntries( + definitions.map(([id, source, property]) => [ + id, + { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: `packages/wallet/wallet-toolbox/src/storage/portable/${property}`, + mutate: [`src/storage/portable/${source}.ts`], + additionalInputs: [ + 'src/storage/portable/index.ts', + 'src/storage/portable/stream.ts', + 'src/storage/portable/node.ts' + ], + ...jestTarget('jest.config.cjs', testMatch, { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + }) + } + ]) + ) +} + function vitestTarget(configFile) { return { testRunner: 'vitest', @@ -927,6 +981,7 @@ export function buildMutationTargets(repositoryRoot) { } ) }, + ...portableStreamMutationTargets(repositoryRoot), ...snapshotSyncMutationTargets(repositoryRoot), 'overlay-linkage': { packageDirectory: 'packages/overlays/topics', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 093d9f02d..37ad601af 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required." + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 56c847ee1..0920c7d02 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -677,6 +677,94 @@ "Direct and retained callbacks preserve exact failure causes and release their database connection.", "Every generated schedule preserves the complete selector/query count without an unbounded queue." ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Allocation-bounded canonical RFC8785 portable serialization and detached source ownership", + "target": "Complete original generated behavioral suite with independent byte, semantic, lifecycle and resource oracles", + "invariants": [ + "Exact independent canonical JSON spelling and raw UTF16 property ordering are retained under arbitrary bounded chunk widths.", + "Finite scalar, Unicode, array-position, ownership and allocation limits refuse invalid values without publishing partial success." + ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Bounded packed-row binary, date and historical JSON projection preserving exact legacy portable semantics", + "target": "Complete original generated behavioral suite with independent byte, semantic, lifecycle and resource oracles", + "invariants": [ + "Generated packed binary and historical optional JSON fields preserve the independent legacy oracle without mutating captured source rows.", + "Per-row allocation admission precedes materialization and never grants whole-process or native quota authority." + ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Complete thirteen-table canonical stream framing, original source validation and awaited cleanup before completion", + "target": "Complete original generated behavioral suite with independent byte, semantic, lifecycle and resource oracles", + "invariants": [ + "Generated thirteen-table documents retain original header/source IDs and exact canonical legacy bytes across bounded chunks.", + "Complete source semantic validation and awaited physical release must precede completion or an authentication tag." + ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Coherent retained SQLite/MySQL thirteen-table keyset sources, bounded payload admission and profile closure", + "target": "Complete original generated behavioral suite with independent byte, semantic, lifecycle and resource oracles", + "invariants": [ + "Generated page widths and native independent-writer schedules retain the exact original thirteen-table coherent source and profile closure.", + "Keyset ordering, original source provenance and bounded admitted pages remain fixed throughout the retained view." + ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Bounded strict UTF8 and complete BRC38 document framing into private staging with semantic completion and cleanup", + "target": "Complete original generated behavioral suite with independent byte, semantic, lifecycle and resource oracles", + "invariants": [ + "Generated byte partitions and document ordering preserve every required table and row against the independent legacy semantic oracle.", + "Truncation, cancellation or private staging failure cannot validate or activate data and must await owned cleanup." + ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.property.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Bounded standard WDAT envelope framing and explicit KDF/file admission preserving complete ciphertext and tag", + "target": "Complete original generated behavioral suite with independent byte, semantic, lifecycle and resource oracles", + "invariants": [ + "Independent standard envelope bytes preserve complete ciphertext and tag through arbitrary bounded fragmentation.", + "Generated authenticated document schedules refuse corruption and retain canonical KDF and private semantic-validation requirements." + ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Node BRC39 native AEAD, canonical Argon2id/NFC, owned key/password cleanup and private staging backpressure", + "target": "Complete original generated behavioral suite with independent byte, semantic, lifecycle and resource oracles", + "invariants": [ + "Generated bounded encrypted exports preserve independent AEAD/legacy bytes and complete tag verification with canonical NFC and Argon2id rules.", + "Owned password/key bytes and source/output operations settle and clean up on cancellation or any exact-cause failure before publication." + ] + }, + { + "path": "packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "risk": "critical", + "boundary": "Node private authenticated-file quarantine, bounded serial I/O, complete verification and physical cleanup", + "target": "Complete original generated behavioral suite with independent byte, semantic, lifecycle and resource oracles", + "invariants": [ + "Generated private-file operations retain exact bytes across bounded short reads/writes and independently verified complete authentication/semantics.", + "Readers, writers and private paths remain owned through settlement and cleanup; provisional data never activates a wallet." + ] } ], "exclusions": [ diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 19bf49d04..1790a12f2 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -52,7 +52,7 @@ opt in with `supportsReadSnapshot` and `readSnapshot`. Use the export option `requireSnapshot: true` to refuse unsupported capture; old custom-provider calls retain their documented caller-quiesced fallback. Recognized optional nullable JSON fields are omitted in a detached archive copy; -array entries and meaningful falsy values are preserved. The helpers still +array entries and meaningful falsy values are preserved. The legacy helpers still materialize the full document/file, and IndexedDB writers wait during capture. Run `pnpm test:snapshot-archive-crash` for native SQLite process recovery and `pnpm test:snapshot-archive-mysql` for the disposable MySQL fixture from this @@ -66,6 +66,13 @@ readiness and the child proof have deadlines. Failure or cancellation drains owned work and attempts exact-owner cleanup before reporting its outcome; unproved cleanup fails qualification. Other wallet shards do not start MySQL. +The unpublished candidate adds optional `@bsv/wallet-toolbox/portable` and +`@bsv/wallet-toolbox/portable/node` streaming entries. They require explicit +resource ceilings, coherent source validation and private staging. They preserve +the existing archive format and materialized APIs. See the +[streaming contracts and remaining limits](https://bsv-blockchain.github.io/ts-stack/guides/wallet-data-portability/#unpublished-bounded-streaming-entries) +before integrating them. Full issue #544 production qualification remains open. + The candidate also contains internal SQL journal primitives for exact revisions, bounded metadata pages, bootstrap with a durable explicit row allowance, source-table observers, and recovery of an diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index c8e18e56e..61670bce8 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -27,7 +27,17 @@ "types": "./out/src/*.d.ts", "require": "./out/src/*.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./portable": { + "types": "./out/src/storage/portable/stream.d.ts", + "require": "./out/src/storage/portable/stream.js", + "default": "./out/src/storage/portable/stream.js" + }, + "./portable/node": { + "types": "./out/src/storage/portable/node.d.ts", + "require": "./out/src/storage/portable/node.js", + "default": "./out/src/storage/portable/node.js" + } }, "files": [ "out/src/", @@ -55,7 +65,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts src/storage/sync/SyncPageBudget.property.test.ts --testPathIgnorePatterns=man.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts src/storage/sync/SyncPageBudget.property.test.ts --testPathIgnorePatterns=man.test.ts src/storage/portable/CanonicalPortableChunks.property.test.ts src/storage/portable/Brc38PackedRow.test.ts src/storage/portable/Brc38Stream.test.ts src/storage/portable/Brc38KnexSource.test.ts src/storage/portable/Brc38JsonStream.property.test.ts src/storage/portable/Brc39Frame.property.test.ts src/storage/portable/Brc39StreamNode.test.ts src/storage/portable/Brc39PrivateFileNode.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", @@ -67,7 +77,7 @@ "format:check": "pnpm --workspace-root exec prettier --check \"packages/wallet/wallet-toolbox/{README.md,jest.config.cjs,package.json,tsconfig*.json}\"", "lint": "oxlint src test benchmarks examples operator --deny-warnings", "lint:ci": "pnpm lint", - "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes cjs --exports Wallet,WalletSigner,WalletStorageManager,StorageClient,StorageKnex,Services,Setup,WalletPermissionsManager,WalletSettingsManager,LocalChainTracker,FixedWindowBulkFileDownloadBudget,DurableFileBulkFileDownloadBudget,BulkFileDataCacheFs,NodeBulkFileDataValidator,sdk --entry-exports \"./out/src/sdk=WalletError|WERR_BAD_REQUEST|WERR_INTERNAL|WERR_INVALID_PARAMETER|WERR_UNAUTHORIZED\" && tsc --project test/consumer/tsconfig.json", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes cjs --exports Wallet,WalletSigner,WalletStorageManager,StorageClient,StorageKnex,Services,Setup,WalletPermissionsManager,WalletSettingsManager,LocalChainTracker,FixedWindowBulkFileDownloadBudget,DurableFileBulkFileDownloadBudget,BulkFileDataCacheFs,NodeBulkFileDataValidator,sdk --entry-exports \"./out/src/sdk=WalletError|WERR_BAD_REQUEST|WERR_INTERNAL|WERR_INVALID_PARAMETER|WERR_UNAUTHORIZED;./portable=canonicalPortableChunks|projectBrc38PackedRow|createBrc38Stream|readBrc38JsonStream|Brc39StreamFrame|encodeBrc39StreamPrefix|BRC39_STREAM_DEFAULT_KDF;./portable/node=canonicalPortableChunks|projectBrc38PackedRow|createBrc38Stream|readBrc38JsonStream|Brc39StreamFrame|encodeBrc39StreamPrefix|BRC39_STREAM_DEFAULT_KDF|openBrc38KnexSource|encryptBrc39StreamToQuarantine|decryptBrc39StreamToQuarantine|createBrc39NodeFileQuarantine\" && tsc --project test/consumer/tsconfig.json", "typecheck": "tsc --build --pretty false && tsc --project examples/tsconfig.json --pretty false && tsc --project operator/tsconfig.json --noEmit --pretty false", "build": "tsc --build", "prepublishOnly": "pnpm build", diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.property.test.ts new file mode 100644 index 000000000..c95c7328a --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.property.test.ts @@ -0,0 +1,129 @@ +import fc from 'fast-check' +import { readBrc38JsonStream, type Brc38JsonStagingSink } from './Brc38JsonStream' +import { jsonStreamFixture } from './Brc38JsonStreamFixture' +import { parseBRC38Json, type BRC38Tables } from './index' + +const MIN_PROPERTY_RUNS = 300 +fc.configureGlobal({ + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true +}) + +const SEED = Number(process.env.FAST_CHECK_SEED ?? 3242026) +test('generated byte partitions, table/root order and row counts agree with the legacy semantic oracle', async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + width: fc.integer({ min: 1, max: 4096 }), + reverse: fc.boolean(), + rows: fc.integer({ min: 0, max: 12 }), + text: fc + .array(fc.constantFrom('a', '🙂', '\u0000', '\\', '"', 'é', ' ', '\n'), { maxLength: 30 }) + .map(values => values.join('')) + }), + async sample => { + const document = jsonStreamFixture() + document.tables.txLabels = Array.from({ length: sample.rows }, (_value, index) => ({ + userId: 7, + txLabelId: 100 + index, + label: sample.text + index, + isDeleted: index % 2 === 0 + })) + document.tables.txLabelMaps = [] + const ordered = sample.reverse ? Object.fromEntries(Object.entries(document).reverse()) : document + const text = JSON.stringify(ordered), + bytes = new TextEncoder().encode(text) + const staged = jsonStreamFixture().tables + for (const table of Object.keys(staged) as Array) staged[table] = [] + let completed = false, + discarded = false + const sink: Brc38JsonStagingSink = { + async provisionalRow(table, index, row) { + expect(index).toBe(staged[table].length) + staged[table].push(row) + }, + async validateCompleted(header, counts) { + expect(parseBRC38Json(JSON.stringify({ ...header, tables: staged }))).toEqual(parseBRC38Json(text)) + for (const table of Object.keys(staged) as Array) + expect(counts[table]).toBe(staged[table].length) + completed = true + }, + async discard() { + discarded = true + } + } + async function* input() { + for (let offset = 0; offset < bytes.length; offset += sample.width) + yield bytes.slice(offset, offset + sample.width) + } + const result = await readBrc38JsonStream(input(), sink, { + maximumArchiveBytes: 1048576, + maximumRowAllocationBytes: 65536 + }) + expect(result.inputBytes).toBe(bytes.length) + expect(completed).toBe(true) + expect(discarded).toBe(false) + } + ), + { + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: SEED, + endOnFailure: true, + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) + +test('generated truncation and early-source completion never validate private staging', async () => { + const text = JSON.stringify(jsonStreamFixture()), + bytes = new TextEncoder().encode(text) + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: bytes.length - 1 }), + fc.integer({ min: 1, max: 1024 }), + async (cut, width) => { + let rows = 0, + complete = false, + discarded = false, + closed = false + const sink: Brc38JsonStagingSink = { + async provisionalRow() { + rows++ + }, + async validateCompleted() { + complete = true + }, + async discard() { + expect(closed).toBe(true) + discarded = true + } + } + async function* input() { + try { + for (let offset = 0; offset < cut; offset += width) yield bytes.slice(offset, Math.min(cut, offset + width)) + } finally { + closed = true + } + } + await expect( + readBrc38JsonStream(input(), sink, { maximumArchiveBytes: 1048576, maximumRowAllocationBytes: 65536 }) + ).rejects.toThrow() + expect(complete).toBe(false) + expect(discarded).toBe(true) + expect(rows).toBeLessThanOrEqual(13) + expect(() => parseBRC38Json(Buffer.from(bytes.subarray(0, cut)).toString())).toThrow() + } + ), + { + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: SEED, + endOnFailure: true, + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.test.ts new file mode 100644 index 000000000..f7457ad65 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.test.ts @@ -0,0 +1,526 @@ +import { readBrc38JsonStream, type Brc38JsonStagingSink, type Brc38JsonStreamOptions } from './Brc38JsonStream' +import { jsonStreamFixture } from './Brc38JsonStreamFixture' +import { parseBRC38Json, type BRC38Tables, type BRC38WalletData } from './index' + +const policy: Brc38JsonStreamOptions = { + maximumArchiveBytes: 1048576, + maximumRowAllocationBytes: 65536, + maximumInputChunkBytes: 65536 +} +async function* chunks(text: string, width = 127) { + const bytes = new TextEncoder().encode(text) + for (let offset = 0; offset < bytes.length; offset += width) yield bytes.slice(offset, offset + width) +} +function staging(): Brc38JsonStagingSink & { staged: BRC38Tables; completed: number; discarded: unknown[] } { + const staged = jsonStreamFixture().tables + for (const name of Object.keys(staged) as Array) staged[name] = [] + const state = { + staged, + completed: 0, + discarded: [] as unknown[], + async provisionalRow(table: keyof BRC38Tables, index: number, row: BRC38Tables[keyof BRC38Tables][number]) { + expect(index).toBe(state.staged[table].length) + expect(Object.isFrozen(row)).toBe(true) + state.staged[table].push(row) + }, + async validateCompleted(header: Readonly, counts: Readonly>) { + expect(Object.isFrozen(header)).toBe(true) + expect(Object.isFrozen(header.user)).toBe(true) + expect(Object.isFrozen(counts)).toBe(true) + for (const table of Object.keys(staged) as Array) + expect(counts[table]).toBe(staged[table].length) + // Materialization is confined to this small independent legacy oracle. + parseBRC38Json(JSON.stringify({ ...header, tables: staged })) + state.completed++ + }, + async discard(cause: unknown) { + state.discarded.push(cause) + } + } + return state +} + +test.each([1, 2, 3, 7, 64, 127, 4096])( + 'all thirteen tables and original provenance agree with legacy JSON at byte width %s', + async width => { + const data = jsonStreamFixture(), + sink = staging(), + text = JSON.stringify(data) + const before = JSON.stringify(data) + const result = await readBrc38JsonStream(chunks(text, width), sink, policy) + expect({ ...result.header, tables: sink.staged }).toEqual(parseBRC38Json(text)) + expect(result.inputBytes).toBe(new TextEncoder().encode(text).length) + expect(sink.completed).toBe(1) + expect(sink.discarded).toEqual([]) + expect(JSON.stringify(data)).toBe(before) + } +) + +test('arbitrary root/table order and escaped punctuation preserve nested historical arrays', async () => { + const data = jsonStreamFixture() + data.tables.provenTxReqs[0].history = { + notes: [{ what: 'string } [ , : \\" 🙂', when: 'original' }], + extra: [true, false, 1e20, []] + } + const reversed = Object.fromEntries(Object.entries(data.tables).reverse()) + const text = JSON.stringify({ + user: data.user, + tables: reversed, + sourceStorage: data.sourceStorage, + title: data.title, + exportedAt: data.exportedAt, + formatVersion: 1, + brc: 38 + }) + const sink = staging() + await readBrc38JsonStream(chunks(' \r\n' + text + '\t ', 3), sink, policy) + expect(sink.staged).toEqual(data.tables) +}) + +test('multi-window rows preserve exact long base64 and Unicode strings', async () => { + const data = jsonStreamFixture() + data.tables.outputs[0].lockingScript = 'ABCD'.repeat(2048) + data.tables.outputTags[0].tag = 'long 🙂 '.repeat(400) + const sink = staging() + await readBrc38JsonStream(chunks(JSON.stringify(data), 7), sink, policy) + expect(sink.staged).toEqual(data.tables) + expect(sink.completed).toBe(1) + expect(sink.discarded).toEqual([]) +}) + +test('queued host cancellation runs between bounded CPU batches on buffered input', async () => { + const controller = new AbortController(), + cause = new Error('host cancel'), + data = jsonStreamFixture() + data.tables.outputs = Array.from({ length: 256 }, (_, index) => ({ ...data.tables.outputs[0], outputId: 16 + index })) + const sink = staging(), + timer = setTimeout(() => controller.abort(cause), 0) + try { + await expect( + readBrc38JsonStream(chunks(JSON.stringify(data), 65536), sink, { ...policy, signal: controller.signal }) + ).rejects.toBe(cause) + expect(sink.completed).toBe(0) + expect(sink.staged.outputs.length).toBeLessThan(32) + expect(sink.discarded).toEqual([cause]) + } finally { + clearTimeout(timer) + } +}) + +test('queued foreground work runs before the buffered archive finishes', async () => { + const data = jsonStreamFixture(), + sink = staging() + data.tables.outputs = Array.from({ length: 256 }, (_, index) => ({ ...data.tables.outputs[0], outputId: 16 + index })) + let ran = false, + firstObserved: number | undefined + const timer = setTimeout(() => { + ran = true + }, 0), + original = sink.provisionalRow + sink.provisionalRow = async (table, index, row) => { + if (table === 'outputs' && ran && firstObserved === undefined) firstObserved = index + await original(table, index, row) + } + try { + await readBrc38JsonStream(chunks(JSON.stringify(data), 65536), sink, policy) + expect(ran).toBe(true) + expect(firstObserved).toBeDefined() + expect(firstObserved).toBeLessThan(32) + expect(sink.completed).toBe(1) + } finally { + clearTimeout(timer) + } +}) + +test('large leading whitespace yields to host cancellation before any staged row', async () => { + const controller = new AbortController(), + cause = new Error('whitespace cancel'), + sink = staging(), + timer = setTimeout(() => controller.abort(cause), 0) + try { + await expect( + readBrc38JsonStream(chunks(' '.repeat(10000) + JSON.stringify(jsonStreamFixture()), 65536), sink, { + ...policy, + signal: controller.signal + }) + ).rejects.toBe(cause) + expect(Object.values(sink.staged).every(rows => rows.length === 0)).toBe(true) + expect(sink.completed).toBe(0) + expect(sink.discarded).toEqual([cause]) + } finally { + clearTimeout(timer) + } +}) + +test.each([ + '', + '{}', + '[]', + '{"tables":{}}', + '{"tables":[]}', + '{"tables":{"outputs":[', + '{"brc":38,}', + '{"brc":38,"brc":38}', + JSON.stringify(jsonStreamFixture()).slice(0, -1), + JSON.stringify(jsonStreamFixture()) + '{}', + '\ufeff' + JSON.stringify(jsonStreamFixture()), + JSON.stringify(jsonStreamFixture()).replace('"outputs":[', '"outputs":[null,'), + JSON.stringify(jsonStreamFixture()).replace('"tag":"tag 🙂"', '"tag":"\\ud800"'), + JSON.stringify(jsonStreamFixture()).replace('"tag":"tag 🙂"', '"tag":"\\udfff"'), + JSON.stringify(jsonStreamFixture()).replace('"userId":7', '"userId":1e999') +])('malformed/trailing/nonportable input refuses before completion: %s', async text => { + const sink = staging() + await expect(readBrc38JsonStream(chunks(text, 2), sink, policy)).rejects.toThrow() + expect(sink.completed).toBe(0) + expect(sink.discarded).toHaveLength(1) +}) + +test('a host yielding a different typed array refuses and closes the private source', async () => { + const next = jest.fn().mockResolvedValue({ done: false, value: new Uint16Array([123]) }) + const close = jest.fn().mockResolvedValue({ done: true, value: undefined }) + const source: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next, return: close }) } + const sink = staging() + await expect(readBrc38JsonStream(source, sink, policy)).rejects.toThrow('Invalid bounded') + expect(next).toHaveBeenCalledTimes(1) + expect(close).toHaveBeenCalledTimes(1) + expect(sink.completed).toBe(0) + expect(sink.discarded).toHaveLength(1) +}) + +test('real chunk bytes are used without reading host length, slice or iterator properties', async () => { + const text = JSON.stringify(jsonStreamFixture()), + bytes = new TextEncoder().encode(text), + sink = staging(), + read = jest.fn(() => { + throw new Error('host property was invoked') + }) + Object.defineProperties(bytes, { + byteLength: { get: read }, + length: { get: read }, + slice: { get: read }, + [Symbol.iterator]: { get: read } + }) + async function* source() { + yield bytes + } + const result = await readBrc38JsonStream(source(), sink, policy) + expect(result.inputBytes).toBe(new TextEncoder().encode(text).length) + expect(sink.staged).toEqual(jsonStreamFixture().tables) + expect(read).not.toHaveBeenCalled() + expect(sink.completed).toBe(1) +}) + +test('a shadowed chunk length cannot bypass the actual archive ceiling', async () => { + const bytes = new TextEncoder().encode(JSON.stringify(jsonStreamFixture())), + sink = staging(), + copy = jest.fn(() => { + throw new Error('copy before limit') + }) + Object.defineProperties(bytes, { byteLength: { value: 0 }, slice: { value: copy } }) + async function* source() { + yield bytes + } + await expect(readBrc38JsonStream(source(), sink, { ...policy, maximumArchiveBytes: 10 })).rejects.toThrow('policy') + expect(copy).not.toHaveBeenCalled() + expect(Object.values(sink.staged).every(rows => rows.length === 0)).toBe(true) + expect(sink.completed).toBe(0) + expect(sink.discarded).toHaveLength(1) +}) + +test('shared backing refuses before any private row can be staged', async () => { + const original = new TextEncoder().encode(JSON.stringify(jsonStreamFixture())), + bytes = new Uint8Array(new SharedArrayBuffer(original.length)), + sink = staging() + bytes.set(original) + async function* source() { + yield bytes + } + await expect(readBrc38JsonStream(source(), sink, policy)).rejects.toThrow() + expect(Object.values(sink.staged).every(rows => rows.length === 0)).toBe(true) + expect(sink.completed).toBe(0) + expect(sink.discarded).toHaveLength(1) +}) + +test('Node Buffer views retain only their actual offset and length', async () => { + const encoded = Buffer.from(JSON.stringify(jsonStreamFixture())), + bytes = Buffer.concat([Buffer.from('prefix'), encoded, Buffer.from('suffix')]).subarray(6, 6 + encoded.length), + sink = staging() + async function* source() { + yield bytes + } + const result = await readBrc38JsonStream(source(), sink, policy) + expect(result.inputBytes).toBe(encoded.length) + expect(sink.staged).toEqual(jsonStreamFixture().tables) + expect(sink.completed).toBe(1) +}) + +test('a repeated table refuses rather than staging its second occurrence', async () => { + const text = JSON.stringify(jsonStreamFixture()).replace('"syncStates":', '"provenTxs":[],"syncStates":') + const sink = staging() + await expect(readBrc38JsonStream(chunks(text), sink, policy)).rejects.toThrow('Invalid bounded') + expect(sink.discarded).toHaveLength(1) + expect(sink.completed).toBe(0) +}) + +test.each([ + { maximumArchiveBytes: 10 }, + { maximumRowAllocationBytes: 128 }, + { maximumMetadataAllocationBytes: 128 }, + { maximumInputChunkBytes: 1 }, + { maximumInputChunks: 1 } +])('fixed input/row/metadata/chunk ceilings refuse with private discard: %j', async changes => { + const sink = staging() + await expect( + readBrc38JsonStream(chunks(JSON.stringify(jsonStreamFixture())), sink, { ...policy, ...changes }) + ).rejects.toThrow('policy') + expect(sink.completed).toBe(0) + expect(sink.discarded).toHaveLength(1) +}) + +test('deep values refuse before JSON.parse or a row callback', async () => { + const text = JSON.stringify(jsonStreamFixture()).replace( + '"provenTxs":[', + '"provenTxs":[' + '['.repeat(65) + '1' + ']'.repeat(65) + ',' + ) + const sink = staging() + await expect(readBrc38JsonStream(chunks(text), sink, policy)).rejects.toThrow('64 levels') + expect(sink.staged.provenTxs).toHaveLength(0) +}) + +test('row and source backpressure retain one private callback in flight', async () => { + let resolve: (() => void) | undefined, rowStarted: (() => void) | undefined + const blocked = new Promise(done => { + resolve = done + }) + const started = new Promise(done => { + rowStarted = done + }) + let reads = 0, + active = 0, + maximum = 0 + const sink = staging(), + original = sink.provisionalRow + sink.provisionalRow = async (...args) => { + maximum = Math.max(maximum, ++active) + rowStarted?.() + await blocked + await original(...args) + active-- + } + async function* input() { + for await (const part of chunks(JSON.stringify(jsonStreamFixture()), 1)) { + reads++ + yield part + } + } + const pending = readBrc38JsonStream(input(), sink, policy) + await started + const atPause = reads + await Promise.resolve() + await Promise.resolve() + expect(reads).toBe(atPause) + resolve?.() + await pending + expect(maximum).toBe(1) +}) + +test('cancellation waits for owned row I/O then closes the source and discards', async () => { + const controller = new AbortController(), + cause = new Error('cancelled by host'), + sink = staging() + const order: string[] = [] + let closed = false + async function* input() { + try { + yield* chunks(JSON.stringify(jsonStreamFixture()), 1) + } finally { + closed = true + order.push('source closed') + } + } + sink.provisionalRow = async () => { + controller.abort(cause) + await Promise.resolve() + order.push('row settled') + } + sink.discard = async error => { + expect(error).toBe(cause) + expect(closed).toBe(true) + order.push('staging discarded') + } + await expect(readBrc38JsonStream(input(), sink, { ...policy, signal: controller.signal })).rejects.toBe(cause) + expect(order).toEqual(['row settled', 'source closed', 'staging discarded']) +}) + +test('semantic completion still refuses an orphan after complete syntax', async () => { + const data = jsonStreamFixture(), + sink = staging() + data.tables.outputs[0].transactionId = 999 + await expect(readBrc38JsonStream(chunks(JSON.stringify(data)), sink, policy)).rejects.toThrow('exported transaction') + expect(sink.completed).toBe(0) + expect(sink.discarded).toHaveLength(1) +}) + +test('source and private cleanup failures retain the exact original cause', async () => { + const original = new Error('source failed'), + cleanup = new Error('source close failed'), + discard = new Error('discard failed') + const source: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: async () => { + throw original + }, + return: async () => { + throw cleanup + } + }) + } + const sink = staging() + sink.discard = async () => { + throw discard + } + let failure: unknown + try { + await readBrc38JsonStream(source, sink, policy) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).cause).toBe(original) + expect((failure as AggregateError).errors).toEqual([original, cleanup, discard]) +}) + +test('invalid UTF-8 and unlimited empty chunks refuse and discard', async () => { + const sink = staging() + async function* invalidBytes() { + yield new Uint8Array([0xff]) + } + await expect(readBrc38JsonStream(invalidBytes(), sink, policy)).rejects.toThrow() + const second = staging() + async function* empty() { + for (;;) yield new Uint8Array() + } + await expect(readBrc38JsonStream(empty(), second, { ...policy, maximumInputChunks: 3 })).rejects.toThrow('policy') + expect(sink.discarded).toHaveLength(1) + expect(second.discarded).toHaveLength(1) +}) + +test.each([ + { maximumArchiveBytes: 0 }, + { maximumArchiveBytes: Number.POSITIVE_INFINITY }, + { maximumRowAllocationBytes: -1 }, + { maximumRowAllocationBytes: 16777217 }, + { maximumMetadataAllocationBytes: 65537 }, + { maximumInputChunkBytes: 65537 }, + { maximumInputChunks: 1.5 } +])('invalid policies refuse before reading any input: %j', async changes => { + let reads = 0 + const sink = staging() + async function* input() { + reads++ + yield* chunks(JSON.stringify(jsonStreamFixture())) + } + await expect(readBrc38JsonStream(input(), sink, { ...policy, ...changes })).rejects.toBeInstanceOf(RangeError) + expect(reads).toBe(0) + expect(sink.discarded).toHaveLength(1) +}) + +test('absent standard tables and non-object rows refuse private completion', async () => { + for (const replacement of ['"txLabels":false', '"removedTable":[]']) { + const text = JSON.stringify(jsonStreamFixture()).replace(/"txLabels":\[[^\]]*\]/, replacement) + const sink = staging() + await expect(readBrc38JsonStream(chunks(text), sink, policy)).rejects.toThrow() + expect(sink.completed).toBe(0) + expect(sink.discarded).toHaveLength(1) + } + const text = JSON.stringify(jsonStreamFixture()).replace('"provenTxs":[', '"provenTxs":[42,') + await expect(readBrc38JsonStream(chunks(text), staging(), policy)).rejects.toThrow('Invalid bounded') +}) + +test('bounded unknown metadata and table properties retain legacy acceptance', async () => { + const document = jsonStreamFixture() + const text = JSON.stringify({ + ...document, + hint: { values: [true, 1, 'original'] }, + tables: { ...document.tables, future: [] } + }) + const sink = staging() + const result = await readBrc38JsonStream(chunks(text), sink, policy) + expect(result.header).toHaveProperty('hint', { values: [true, 1, 'original'] }) + expect(sink.staged).toEqual(document.tables) +}) + +test('source cleanup failure prevents even the semantic completion callback', async () => { + const cause = new Error('physical source cleanup failed'), + iterator = chunks(JSON.stringify(jsonStreamFixture())) + const input: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => iterator.next(), + return: async () => { + throw cause + } + }) + } + const sink = staging() + await expect(readBrc38JsonStream(input, sink, policy)).rejects.toBe(cause) + expect(sink.completed).toBe(0) + expect(sink.discarded).toEqual([cause]) +}) + +test('already cancelled input is never read and semantic failure preserves its identity', async () => { + const controller = new AbortController(), + cancellation = new Error('already cancelled') + controller.abort(cancellation) + let reads = 0 + async function* input() { + reads++ + yield* chunks(JSON.stringify(jsonStreamFixture())) + } + const first = staging() + await expect(readBrc38JsonStream(input(), first, { ...policy, signal: controller.signal })).rejects.toBe(cancellation) + expect(reads).toBe(0) + const second = staging(), + semantic = new Error('staged proof rejected') + second.validateCompleted = async () => { + throw semantic + } + await expect(readBrc38JsonStream(chunks(JSON.stringify(jsonStreamFixture())), second, policy)).rejects.toBe(semantic) + expect(second.discarded).toEqual([semantic]) +}) + +test('cancellation during final private validation discards after that I/O settles', async () => { + const controller = new AbortController(), + cause = new Error('cancelled at semantic validation'), + sink = staging() + let settled = false + sink.validateCompleted = async () => { + controller.abort(cause) + await Promise.resolve() + settled = true + } + sink.discard = async error => { + expect(settled).toBe(true) + expect(error).toBe(cause) + } + await expect( + readBrc38JsonStream(chunks(JSON.stringify(jsonStreamFixture())), sink, { ...policy, signal: controller.signal }) + ).rejects.toBe(cause) +}) + +test('a source without a return hook still closes successfully at EOF', async () => { + const iterator = chunks(JSON.stringify(jsonStreamFixture())) + const input: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => iterator.next() }) } + const sink = staging() + await readBrc38JsonStream(input, sink, policy) + expect(sink.completed).toBe(1) +}) + +test('metadata field cardinality is fixed before allocating another property', async () => { + const document = { + ...jsonStreamFixture(), + ...Object.fromEntries(Array.from({ length: 58 }, (_, index) => ['extra' + index, true])) + } + const sink = staging() + await expect(readBrc38JsonStream(chunks(JSON.stringify(document)), sink, policy)).rejects.toThrow('Invalid bounded') + expect(sink.completed).toBe(0) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.ts new file mode 100644 index 000000000..b0bb42eb5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.ts @@ -0,0 +1,459 @@ +import { parseBRC38Json, type BRC38Tables, type BRC38WalletData } from './index' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' +import { runInSeries } from '../../utility/runInSeries' + +type Table = keyof BRC38Tables +type Row = BRC38Tables[Table][number] +type Value = string | number | boolean | Value[] | { [key: string]: Value } +export interface Brc38JsonStreamOptions { + maximumArchiveBytes: number + /** Conservative parsed-node/string charge for ONE provisional row. */ + maximumRowAllocationBytes: number + /** All non-table metadata together, default 65,536 allocation bytes. */ + maximumMetadataAllocationBytes?: number + maximumInputChunkBytes?: number + /** Includes empty chunks, so a producer cannot make unlimited zero-byte progress. */ + maximumInputChunks?: number + signal?: AbortSignal +} +export interface Brc38JsonStagingSink { + /** Private staging only. Awaited before reading the next row. No active wallet + * writes, ID activation or capability publication may occur here. */ + provisionalRow: (table: Table, index: number, row: Readonly) => Promise + /** Independently validate every staged row, unique key, relation, profile and + * original source/provenance. This callback must not activate the import. + * Syntax/header completion alone is not complete portable semantic validation. */ + validateCompleted: (header: Readonly, counts: Readonly>) => Promise + /** Wait for owned staging I/O and discard this private import after any error. */ + discard: (cause: unknown) => Promise +} +export interface Brc38JsonStreamResult { + readonly header: Readonly + readonly counts: Readonly> + readonly inputBytes: number +} +const tables: readonly Table[] = Object.freeze([ + 'provenTxs', + 'provenTxReqs', + 'outputBaskets', + 'transactions', + 'commissions', + 'outputs', + 'outputTags', + 'outputTagMaps', + 'txLabels', + 'txLabelMaps', + 'certificates', + 'certificateFields', + 'syncStates' +]) +const isTable = (value: string): value is Table => tables.some(table => table === value) +const typedArrayPrototype: object = Object.getPrototypeOf(Uint8Array.prototype) +const byteLengthGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'byteLength')!.get! +const bufferGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'buffer')!.get! +const tagGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, Symbol.toStringTag)!.get! +const arrayBufferLengthGetter = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')!.get! +const copyBytes = Uint8Array.prototype.set +const whitespace = (unit: string | undefined): boolean => + unit === ' ' || unit === '\t' || unit === '\r' || unit === '\n' +function invalid(): never { + throw new TypeError('Invalid bounded BRC-38 JSON stream') +} +function inputLength(value: Uint8Array): number { + if (Reflect.apply(tagGetter, value, []) !== 'Uint8Array') invalid() + const buffer: unknown = Reflect.apply(bufferGetter, value, []) + // Ordinary ArrayBuffer ownership is required. Its intrinsic getter refuses + // shared backing without reading caller-controlled properties or callbacks. + Reflect.apply(arrayBufferLengthGetter, buffer, []) + return Reflect.apply(byteLengthGetter, value, []) as number +} +function integer(value: number, maximum: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) + throw new RangeError(`${name} must be an integer from 1 to ${maximum}`) + return value +} +function object(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} +function unicode(value: string): void { + for (let index = 0; index < value.length; index++) { + const unit = value.charCodeAt(index) + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++index) + if (!(next >= 0xdc00 && next <= 0xdfff)) invalid() + } else if (unit >= 0xdc00 && unit <= 0xdfff) invalid() + } +} +function portable(value: unknown): asserts value is Value { + // ValueFrame has already bounded the complete nesting before JSON.parse. + if (typeof value === 'string') unicode(value) + else if (typeof value === 'number') { + if (!Number.isFinite(value)) invalid() + } else if (typeof value === 'boolean') return + else if (Array.isArray(value)) { + for (const child of value) portable(child) + } else if (object(value)) { + for (const key of Object.keys(value)) { + unicode(key) + portable(value[key]) + } + } else invalid() +} +function frozen(value: T): T { + if (value !== null && typeof value === 'object') { + for (const child of Object.values(value)) frozen(child) + Object.freeze(value) + } + return value +} +interface Policy { + maximumArchiveBytes: number + maximumInputChunkBytes: number + maximumInputChunks: number + signal: AbortSignal | undefined +} +function* pending(continues: () => boolean): Generator { + while (continues()) yield undefined +} +class ValueFrame { + readonly container: boolean + readonly string: boolean + quoted = false + escaped = false + atom = false + depth = 0 + allocation = 0 + window = '' + readonly pieces: string[] = [] + constructor( + first: string, + readonly maximum: number + ) { + this.container = first === '{' || first === '[' + this.string = first === '"' + } + charge(bytes: number): void { + if (bytes > this.maximum - this.allocation) + throw new SnapshotResourceLimitError('BRC-38 JSON value exceeds its allocation policy') + this.allocation += bytes + } + delimiter(unit: string): boolean { + return !this.quoted && !this.container && !this.string && (whitespace(unit) || ',:]}'.includes(unit)) + } + quotedUnit(unit: string): void { + if (this.escaped) this.escaped = false + else if (unit === '\\') this.escaped = true + else if (unit === '"') this.quoted = false + } + structuralUnit(unit: string): void { + if (unit === '"') { + this.charge(128) + this.quoted = true + this.atom = false + } else if (unit === '{' || unit === '[') { + this.charge(128) + if (++this.depth > 64) throw new SnapshotResourceLimitError('BRC-38 JSON nesting exceeds 64 levels') + this.atom = false + } else if (unit === '}' || unit === ']') { + this.depth-- + this.atom = false + } else if (whitespace(unit) || unit === ',' || unit === ':') this.atom = false + else if (!this.atom) { + this.charge(128) + this.atom = true + } + } + accept(unit: string): boolean { + this.charge(6) + if (this.quoted) this.quotedUnit(unit) + else this.structuralUnit(unit) + this.window += unit + if (this.window.length === 1024) { + this.pieces.push(this.window) + this.window = '' + } + return (this.container && this.depth === 0) || (this.string && !this.quoted) + } + finish(): { value: Value; allocation: number } { + if (this.quoted || this.escaped || this.depth !== 0) invalid() + this.pieces.push(this.window) + const value: unknown = JSON.parse(this.pieces.join('')) + portable(value) + return { value, allocation: this.allocation } + } +} +class Input { + readonly iterator: AsyncIterator + readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }) + buffer = '' + offset = 0 + done = false + inputBytes = 0 + chunks = 0 + workUnits = 0 + closing: Promise | undefined + constructor( + source: AsyncIterable, + readonly policy: Policy + ) { + this.iterator = source[Symbol.asyncIterator]() + } + async peek(): Promise { + await this.yieldIfNeeded() + this.policy.signal?.throwIfAborted() + await runInSeries( + pending(() => this.offset === this.buffer.length && !this.done), + () => this.refill() + ) + return this.offset === this.buffer.length ? undefined : this.buffer[this.offset] + } + advance(): void { + this.offset++ + this.workUnits++ + } + async yieldIfNeeded(): Promise { + if (this.workUnits >= 4096) { + this.workUnits = 0 + // Resolved iterator/staging promises do not yield to timers or host + // messages. Give cancellation and foreground work an actual task turn. + await new Promise(done => setTimeout(done, 0)) + } + this.policy.signal?.throwIfAborted() + } + async refill(): Promise { + // The host must bound source I/O and settle it on cancellation. Never + // abandon an in-flight iterator read and release its physical ownership. + const next = await this.iterator.next() + this.policy.signal?.throwIfAborted() + this.offset = 0 + if (next.done === true) { + this.done = true + this.buffer = this.decoder.decode() + return + } + const length = inputLength(next.value) + if ( + ++this.chunks > this.policy.maximumInputChunks || + length > this.policy.maximumInputChunkBytes || + length > this.policy.maximumArchiveBytes - this.inputBytes + ) + throw new SnapshotResourceLimitError('BRC-38 JSON input exceeds the selected stream policy') + const detached = new Uint8Array(length) + Reflect.apply(copyBytes, detached, [next.value]) + this.inputBytes += length + this.buffer = this.decoder.decode(detached, { stream: true }) + } + async skip(): Promise { + let more = true + await runInSeries( + pending(() => more), + async () => { + more = whitespace(await this.peek()) + if (more) this.advance() + } + ) + } + async expect(unit: string): Promise { + await this.skip() + if ((await this.peek()) !== unit) invalid() + this.advance() + } + close(): Promise { + this.closing ??= Promise.resolve().then(async () => { + if (this.iterator.return !== undefined) await this.iterator.return() + }) + return this.closing + } + /** Charge raw buffered/joined/parsed text and every possible parsed node + * BEFORE JSON.parse. Only this one value can be materialized. */ + async value(maximum: number): Promise<{ value: Value; allocation: number }> { + await this.skip() + const first = await this.peek() + if (first === undefined || ',:]}'.includes(first)) invalid() + const frame = new ValueFrame(first, maximum) + let more = true + await runInSeries( + pending(() => more), + async () => { + if ((await this.peek()) === undefined) { + more = false + return + } + // Scan only the current decoded chunk and a fixed work allowance. + // No await/promise allocation per character, and no unbounded CPU run. + const end = Math.min(this.buffer.length, this.offset + 4096 - this.workUnits) + while (more && this.offset < end) { + const unit = this.buffer[this.offset] + if (frame.delimiter(unit)) { + more = false + break + } + more = !frame.accept(unit) + this.advance() + } + } + ) + await this.yieldIfNeeded() + return frame.finish() + } +} + +class Document { + readonly fields = new Set() + readonly tableFields = new Set() + readonly metadata = new Map() + readonly empty: BRC38Tables = { + provenTxs: [], + provenTxReqs: [], + outputBaskets: [], + transactions: [], + commissions: [], + outputs: [], + outputTags: [], + outputTagMaps: [], + txLabels: [], + txLabelMaps: [], + certificates: [], + certificateFields: [], + syncStates: [] + } + readonly counts = Object.fromEntries(tables.map(table => [table, 0])) as Record + readonly row: Brc38JsonStagingSink['provisionalRow'] + constructor( + readonly reader: Input, + sink: Brc38JsonStagingSink, + readonly maximumRow: number, + public remainingMetadata: number + ) { + this.row = sink.provisionalRow.bind(sink) + } + async metadataValue(): Promise { + const result = await this.reader.value(this.remainingMetadata) + this.remainingMetadata -= result.allocation + return result.value + } + async key(seen: Set): Promise { + const value = await this.metadataValue() + if (typeof value !== 'string' || seen.has(value) || seen.size >= 64) invalid() + seen.add(value) + await this.reader.expect(':') + return value + } + async separator(end: string): Promise { + await this.reader.skip() + const separator = await this.reader.peek() + if (separator === undefined) invalid() + this.reader.advance() + if (separator === end) return false + if (separator !== ',') invalid() + return true + } + async tableRows(table: Table): Promise { + await this.reader.expect('[') + await this.reader.skip() + if ((await this.reader.peek()) === ']') { + this.reader.advance() + return + } + let more = true + await runInSeries( + pending(() => more), + async () => { + const result = await this.reader.value(this.maximumRow) + if (!object(result.value) || this.counts[table] === Number.MAX_SAFE_INTEGER) invalid() + await this.row(table, this.counts[table], frozen(result.value)) + this.counts[table]++ + more = await this.separator(']') + } + ) + } + async tableObject(): Promise { + await this.reader.expect('{') + let more = true + await runInSeries( + pending(() => more), + async () => { + const name = await this.key(this.tableFields) + if (isTable(name)) await this.tableRows(name) + else await this.metadataValue() + more = await this.separator('}') + } + ) + if (!tables.every(table => this.tableFields.has(table))) invalid() + } + async read(): Promise> { + await this.reader.expect('{') + let more = true + await runInSeries( + pending(() => more), + async () => { + const name = await this.key(this.fields) + if (name === 'tables') await this.tableObject() + else this.metadata.set(name, await this.metadataValue()) + more = await this.separator('}') + } + ) + await this.reader.skip() + if ((await this.reader.peek()) !== undefined || !this.fields.has('tables')) invalid() + // Only bounded metadata and thirteen empty arrays reach the unchanged + // legacy header validator. Complete row relationships belong to staging. + return frozen(parseBRC38Json(JSON.stringify({ ...Object.fromEntries(this.metadata), tables: this.empty }))) + } +} +async function discard(input: Input | undefined, sink: Brc38JsonStagingSink, error: unknown): Promise { + const failures = [error] + try { + await input?.close() + } catch (cleanup) { + if (cleanup !== error) failures.push(cleanup) + } + try { + await sink.discard(error) + } catch (cleanup) { + failures.push(cleanup) + } + if (failures.length > 1) + throw new AggregateError(failures, 'BRC-38 JSON reading and staging cleanup failed', { cause: error }) + throw error +} + +/** Bounded framing into private staging, not durable import activation. Every + * standard table is required exactly once; rows and input chunks are bounded, + * one callback is in flight and trailing/truncated/malformed input refuses. + * The caller supplies already authenticated plaintext or an independently + * authenticated private quarantine. It must provide real semantic/closure + * validation and physical staging cleanup before using the result. */ +export async function readBrc38JsonStream( + source: AsyncIterable, + sink: Brc38JsonStagingSink, + selected: Brc38JsonStreamOptions +): Promise { + let input: Input | undefined + try { + const options = Object.freeze({ ...selected }) + const maximumRow = integer(options.maximumRowAllocationBytes, 16777216, 'maximumRowAllocationBytes') + const remainingMetadata = integer( + options.maximumMetadataAllocationBytes ?? 65536, + 65536, + 'maximumMetadataAllocationBytes' + ) + input = new Input(source, { + maximumArchiveBytes: integer(options.maximumArchiveBytes, Number.MAX_SAFE_INTEGER, 'maximumArchiveBytes'), + maximumInputChunkBytes: integer(options.maximumInputChunkBytes ?? 65536, 65536, 'maximumInputChunkBytes'), + maximumInputChunks: integer(options.maximumInputChunks ?? 1000000, Number.MAX_SAFE_INTEGER, 'maximumInputChunks'), + signal: options.signal + }) + const reader = input + const complete = sink.validateCompleted.bind(sink) + const document = new Document(reader, sink, maximumRow, remainingMetadata) + const header = await document.read() + await reader.close() + options.signal?.throwIfAborted() + const detachedCounts = Object.freeze({ ...document.counts }) + await complete(header, detachedCounts) + options.signal?.throwIfAborted() + return Object.freeze({ header, counts: detachedCounts, inputBytes: reader.inputBytes }) + } catch (error) { + return discard(input, sink, error) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStreamFixture.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStreamFixture.ts new file mode 100644 index 000000000..77cf200df --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStreamFixture.ts @@ -0,0 +1,32 @@ +import type { BRC38WalletData } from './index' + +/** Bounded synthetic document shared by the independent legacy-parser oracle. */ +export function jsonStreamFixture(): BRC38WalletData { + const time = '2026-10-03T00:00:00.000Z' + const times = { created_at: time, updated_at: time } + return { + brc: 38, + title: 'User Wallet Data Format', + formatVersion: 1, + exportedAt: time, + sourceStorage: { ...times, storageIdentityKey: 'original-source', storageName: 'original source', chain: 'test' }, + user: { ...times, userId: 7, identityKey: 'original-identity', activeStorage: 'original-source' }, + tables: { + provenTxs: [{ ...times, provenTxId: 11, txid: 'a'.repeat(64), rawTx: 'AQI=', merklePath: 'AwQ=' }], + provenTxReqs: [{ ...times, provenTxReqId: 12, txid: 'a'.repeat(64), provenTxId: 11, history: { notes: [] } }], + outputBaskets: [{ ...times, basketId: 13, userId: 7, name: 'default', isDeleted: false }], + transactions: [{ ...times, transactionId: 14, userId: 7, txid: 'a'.repeat(64), provenTxId: 11 }], + commissions: [{ ...times, commissionId: 15, userId: 7, transactionId: 14 }], + outputs: [ + { ...times, outputId: 16, userId: 7, transactionId: 14, basketId: 13, spentBy: 14, lockingScript: 'AQI=' } + ], + outputTags: [{ ...times, outputTagId: 17, userId: 7, tag: 'tag 🙂', isDeleted: true }], + outputTagMaps: [{ ...times, outputId: 16, outputTagId: 17 }], + txLabels: [{ ...times, txLabelId: 18, userId: 7, label: 'label', isDeleted: false }], + txLabelMaps: [{ ...times, transactionId: 14, txLabelId: 18 }], + certificates: [{ ...times, certificateId: 19, userId: 7 }], + certificateFields: [{ ...times, certificateId: 19, userId: 7, fieldName: 'name', fieldValue: 'value' }], + syncStates: [{ ...times, syncStateId: 20, userId: 7, storageIdentityKey: 'original-source', syncMap: {} }] + } + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts new file mode 100644 index 000000000..7f375ede1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts @@ -0,0 +1,439 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { knex } from 'knex' +import fc from 'fast-check' +import { StorageKnex } from '../StorageKnex' +import { StorageProvider } from '../StorageProvider' +import { runInSeries } from '../../utility/runInSeries' +import { openBrc38KnexSource, type Brc38KnexSourceOptions } from './Brc38KnexSource' +import { createBrc38Stream } from './Brc38Stream' +import { exportBRC38, parseBRC38Json, type BRC38WalletData } from './index' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' + +const MIN_PROPERTY_RUNS = 300 +fc.configureGlobal({ + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true +}) + +const identity = '02' + '11'.repeat(32) +const foreignIdentity = '03' + '22'.repeat(32) +const date = '2026-01-01T00:00:00.000Z' +const timestamp = { created_at: date, updated_at: date } +const stores: StorageKnex[] = [], + directories: string[] = [] +const options: Brc38KnexSourceOptions = { + maximumPageRows: 1, + maximumPageBytes: 65536, + maximumRowAllocationBytes: 65536, + maximumCertificateGroupBytes: 65536, + lifetimeMs: 300000 +} +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'brc38-coherent-')) + directories.push(directory) + const open = () => { + const storage = new StorageKnex({ + ...StorageProvider.createStorageBaseOptions('test'), + knex: knex({ + client: 'better-sqlite3', + connection: { filename: join(directory, 'wallet.sqlite') }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + acquireConnectionTimeout: 1000 + }) + }) + stores.push(storage) + return storage + } + const source = open() + await source.knex.raw('PRAGMA journal_mode = WAL') + await source.migrate('original source', 'source-storage') + await source.makeAvailable() + const { user } = await source.findOrInsertUser(identity), + { user: other } = await source.findOrInsertUser(foreignIdentity) + const writer = open() + await writer.makeAvailable() + return { source, writer, userId: user.userId, otherId: other.userId } +} +afterEach(async () => { + jest.restoreAllMocks() + await runInSeries(stores.splice(0), storage => storage.destroy()) + await runInSeries(directories.splice(0), directory => rm(directory, { recursive: true, force: true })) +}) +async function collect(chunks: AsyncIterable): Promise { + const result: Uint8Array[] = [] + for await (const bytes of chunks) { + expect(bytes.length).toBeLessThanOrEqual(64) + result.push(bytes) + } + return Buffer.concat(result) +} +async function archive(source: StorageKnex, exportedAt: string, selected = options): Promise { + const owner = await openBrc38KnexSource(source, identity, selected) + const stream = await createBrc38Stream(owner, { + exportedAt, + maximumArchiveBytes: 1048576, + maximumRowBytes: 65536, + maximumChunkBytes: 64 + }) + try { + const data = parseBRC38Json((await collect(stream.chunks)).toString()) + await stream.validateCompleted() + return data + } finally { + await stream.close() + } +} + +async function seedProofs(k: StorageKnex['knex'], id: number, userId: number, otherId: number): Promise { + await k('proven_txs').insert({ + ...timestamp, + provenTxId: id, + txid: String(id).repeat(64), + height: id, + index: 0, + merklePath: Buffer.from([id, 0, 255]), + rawTx: Buffer.from([id, 1, 255]), + blockHash: 'a'.repeat(64), + merkleRoot: 'b'.repeat(64) + }) + await k('transactions').insert({ + ...timestamp, + transactionId: id, + userId: id === 2 ? otherId : userId, + provenTxId: id === 3 ? null : id, + status: 'completed', + reference: `tx-${id}`, + isOutgoing: true, + satoshis: 0, + description: `tx-${id}`, + txid: String(id).repeat(64), + rawTx: Buffer.from([id, 2, 255]), + inputBEEF: Buffer.from([id, 3, 255]) + }) + await k('proven_tx_reqs').insert({ + ...timestamp, + provenTxReqId: id, + provenTxId: id, + txid: String(id).repeat(64), + status: 'completed', + attempts: 0, + notified: true, + history: '{}', + notify: '{}', + rawTx: Buffer.from([id, 4, 255]), + wasBroadcast: true + }) +} +async function seedOutputs(k: StorageKnex['knex'], id: number, userId: number, otherId: number): Promise { + await k('output_baskets').insert({ + ...timestamp, + basketId: id, + userId: id === 2 ? otherId : userId, + name: `basket-${id}`, + isDeleted: id === 3 + }) + await k('outputs').insert({ + ...timestamp, + outputId: id, + userId: id === 2 ? otherId : userId, + transactionId: id, + basketId: id, + spendable: false, + change: true, + vout: 0, + satoshis: 1, + providedBy: 'you', + purpose: '', + type: 'P2PKH', + lockingScript: Buffer.from([id, 5, 255]) + }) + await k('commissions').insert({ + ...timestamp, + commissionId: id, + userId: id === 2 ? otherId : userId, + transactionId: id, + satoshis: 0, + keyOffset: 'offset', + isRedeemed: true, + lockingScript: Buffer.from([id, 6, 255]) + }) + await k('output_tags').insert({ + ...timestamp, + outputTagId: id, + userId: id === 2 ? otherId : userId, + tag: `tag-${id}`, + isDeleted: id === 3 + }) + await k('output_tags_map').insert({ ...timestamp, outputTagId: id, outputId: id, isDeleted: id === 3 }) + await k('tx_labels').insert({ + ...timestamp, + txLabelId: id, + userId: id === 2 ? otherId : userId, + label: `label-${id}`, + isDeleted: id === 3 + }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: id, transactionId: id, isDeleted: id === 3 }) +} +async function seedCertificates(k: StorageKnex['knex'], id: number, userId: number, otherId: number): Promise { + await k('certificates').insert({ + ...timestamp, + certificateId: id, + userId: id === 2 ? otherId : userId, + serialNumber: `serial-${id}`, + type: 'type', + certifier: identity, + subject: identity, + revocationOutpoint: 'a'.repeat(64) + '.0', + signature: 'signature', + isDeleted: id === 3 + }) + for (const fieldName of ['a', 'Z', 'é', '😀']) + await k('certificate_fields').insert({ + ...timestamp, + certificateId: id, + userId: id === 2 ? otherId : userId, + fieldName, + fieldValue: `value-${id}`, + masterKey: 'key' + }) +} +async function seedStates(k: StorageKnex['knex'], id: number, userId: number, otherId: number): Promise { + await k('sync_states').insert({ + ...timestamp, + syncStateId: id, + userId: id === 2 ? otherId : userId, + storageIdentityKey: `peer-${id}`, + storageName: `peer-${id}`, + status: 'unknown', + init: true, + refNum: `state-${id}`, + syncMap: '{}', + when: date + }) +} +async function seedClosure(source: StorageKnex, userId: number, otherId: number): Promise { + const k = source.knex + await k('output_baskets').del() + for (const id of [1, 2, 3]) { + await seedProofs(k, id, userId, otherId) + await seedOutputs(k, id, userId, otherId) + await seedCertificates(k, id, userId, otherId) + await seedStates(k, id, userId, otherId) + } + + // Composite positions must handle repeated first keys and preserve deleted mappings. + await k('output_tags_map').insert({ ...timestamp, outputTagId: 1, outputId: 3, isDeleted: true }) + await k('tx_labels_map').insert({ ...timestamp, txLabelId: 1, transactionId: 3, isDeleted: true }) +} + +test('actual thirteen-table WAL capture matches the independent original exporter including portable map and locale order', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + await source + .knex('proven_tx_reqs') + .where({ provenTxReqId: 1 }) + .update({ history: '{"notes":[{"what":"original","when":null,"code":0}]}', notify: '{"transactionIds":null}' }) + const original = await exportBRC38(source, identity, { requireSnapshot: true }) + const captured = await archive(source, original.exportedAt) + expect(captured).toEqual(original) + expect(Object.values(captured.tables).every(rows => rows.length > 0)).toBe(true) + expect(captured.tables.outputTagMaps.map(row => [row.outputId, row.outputTagId])).toEqual([ + [1, 1], + [3, 1], + [3, 3] + ]) + expect(captured.tables.txLabelMaps.map(row => [row.transactionId, row.txLabelId])).toEqual([ + [1, 1], + [3, 1], + [3, 3] + ]) + expect(captured.tables.certificateFields.filter(row => row.certificateId === 1).map(row => row.fieldName)).toEqual( + ['a', 'Z', 'é', '😀'].sort((first, second) => first.localeCompare(second)) + ) + expect(captured.tables.provenTxs.map(row => row.provenTxId)).toEqual([1, 3]) + expect(captured.tables.syncStates.map(row => row.storageIdentityKey)).toEqual(['peer-1', 'peer-3']) +}) + +test('an independent WAL writer can commit while every archive table and original primary history remain in one source view', async () => { + const { source, writer, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + const original = await exportBRC38(source, identity, { requireSnapshot: true }) + const owner = await openBrc38KnexSource(source, identity, options) + await writer.transaction(async trx => { + await writer.updateUser(userId, { activeStorage: 'later-primary', updated_at: new Date() }, trx) + await writer.updateTxLabel(1, { label: 'later-label' }, trx) + await writer.updateSyncState(1, { syncMap: '{"transaction":{"count":99,"idMap":{}}}' }, trx) + }) + const stream = await createBrc38Stream(owner, { + exportedAt: original.exportedAt, + maximumArchiveBytes: 1048576, + maximumRowBytes: 65536, + maximumChunkBytes: 64 + }) + try { + expect(parseBRC38Json((await collect(stream.chunks)).toString())).toEqual(original) + await stream.validateCompleted() + } finally { + await stream.close() + } + expect((await writer.findUserByIdentityKey(identity))?.activeStorage).toBe('later-primary') + expect((await writer.findTxLabels({ partial: { txLabelId: 1 } }))[0].label).toBe('later-label') +}) + +test('a source orphan refuses during opening and awaits physical close', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + await source.knex('outputs').where({ outputId: 1 }).update({ basketId: 2 }) + const open = source.openReadSnapshot.bind(source) + let closed = false + jest.spyOn(source, 'openReadSnapshot').mockImplementationOnce(async selected => { + const view = await open(selected), + close = view.close + return { + ...view, + close: async () => { + try { + await close() + } finally { + closed = true + } + } + } + }) + await expect(openBrc38KnexSource(source, identity, options)).rejects.toThrow('cross-profile') + expect(closed).toBe(true) +}) + +test('large stored payloads refuse before the driver fetches them and clean up the source', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + await source + .knex('proven_txs') + .where({ provenTxId: 1 }) + .update({ rawTx: Buffer.alloc(65536) }) + const queries: string[] = [] + source.knex.on('query', (query: { sql: string }) => queries.push(query.sql)) + await expect(archive(source, date, { ...options, maximumPageBytes: 32768 })).rejects.toBeInstanceOf( + SnapshotResourceLimitError + ) + expect(queries.some(query => query.includes('`proven_txs`.*'))).toBe(false) +}) + +test('certificate groups refuse at the explicit allocation bound without completing a partial archive', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + await expect(archive(source, date, { ...options, maximumCertificateGroupBytes: 1200 })).rejects.toBeInstanceOf( + SnapshotResourceLimitError + ) +}) + +test('table operations refuse concurrent consumption, duplication and incomplete source validation', async () => { + const { source } = await fixture(), + owner = await openBrc38KnexSource(source, identity, options) + try { + await expect(owner.validateCompleted()).rejects.toThrow('did not complete') + const first = owner.rows('outputBaskets')[Symbol.asyncIterator]() + await first.next() + const second = owner.rows('txLabels')[Symbol.asyncIterator]() + await expect(second.next()).rejects.toThrow('one complete read') + await first.return?.() + const consumed = [] + for await (const row of owner.rows('txLabels')) consumed.push(row) + expect(consumed).toEqual([]) + const duplicate = owner.rows('txLabels')[Symbol.asyncIterator]() + await expect(duplicate.next()).rejects.toThrow('one complete read') + } finally { + await owner.release() + } +}) + +test('opening error and physical close failure both retain exact causes', async () => { + const { source } = await fixture(), + problem = new Error('header failed'), + cleanup = new Error('close failed') + const open = source.openReadSnapshot.bind(source) + jest.spyOn(source, 'openReadSnapshot').mockImplementationOnce(async selected => { + const view = await open(selected) + return { + ...view, + read: async () => { + throw problem + }, + close: async () => { + await view.close() + throw cleanup + } + } + }) + let error: unknown + try { + await openBrc38KnexSource(source, identity, options) + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([problem, cleanup]) + expect((error as AggregateError).cause).toBe(problem) +}) + +test.each([ + 'maximumPageRows', + 'maximumPageBytes', + 'maximumRowAllocationBytes', + 'maximumCertificateGroupBytes' +] as const)('invalid %s refuses before source acquisition', async name => { + const { source } = await fixture(), + open = jest.spyOn(source, 'openReadSnapshot') + await expect(openBrc38KnexSource(source, identity, { ...options, [name]: 0 })).rejects.toThrow(RangeError) + expect(open).not.toHaveBeenCalled() +}) + +test('generated bounded keyset sizes preserve the complete original thirteen-table archive', async () => { + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + const original = await exportBRC38(source, identity, { requireSnapshot: true }) + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 16 }), async maximumPageRows => { + const captured = await archive(source, original.exportedAt, { ...options, maximumPageRows }) + expect(captured).toEqual(original) + }), + { + numRuns: Math.max(300, Number(process.env.FAST_CHECK_NUM_RUNS ?? 300)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) + +test.each([ + ['é', 'e\u0301'], + ['e\u0301', 'é'] +])('locale-equal distinct field names preserve the original SQLite exporter order (%#)', async (first, second) => { + expect(first.localeCompare(second)).toBe(0) + const { source, userId, otherId } = await fixture() + await seedClosure(source, userId, otherId) + await source.knex('certificate_fields').where({ certificateId: 1 }).del() + await runInSeries([first, second], fieldName => + source + .knex('certificate_fields') + .insert({ + ...timestamp, + certificateId: 1, + userId, + fieldName, + fieldValue: fieldName, + masterKey: 'original' + }) + .then(() => undefined) + ) + const original = await exportBRC38(source, identity, { requireSnapshot: true }) + expect(await archive(source, original.exportedAt)).toEqual(original) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.ts new file mode 100644 index 000000000..8a458bf8f --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.ts @@ -0,0 +1,336 @@ +import type { Knex } from 'knex' +import type { StorageKnex } from '../StorageKnex' +import type { RetainedReadSnapshot } from '../snapshot/RetainedReadSnapshot' +import type { WalletReadSnapshotOptions, WalletSnapshotTable } from '../snapshot/WalletReadSnapshot' +import { walletSnapshotSourceQuery } from '../snapshot/KnexWalletReadSnapshot' +import { readKnexSnapshotArchiveHeader } from '../snapshot/archive/KnexSnapshotArchiveSource' +import { assertKnexSnapshotArchiveClosure } from '../snapshot/archive/KnexSnapshotArchiveClosure' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' +import { projectBrc38PackedRow } from './Brc38PackedRow' +import type { BRC38Tables } from './index' +import type { Brc38StreamSource } from './Brc38Stream' + +type Row = BRC38Tables[WalletSnapshotTable][number] +interface SourceRow { + row: Row + ordinal?: bigint +} +interface Definition { + name: string + keys: readonly string[] + booleans?: readonly string[] + dates?: readonly string[] +} +const definitions: Record = { + provenTxs: { name: 'proven_txs', keys: ['provenTxId'] }, + provenTxReqs: { name: 'proven_tx_reqs', keys: ['provenTxReqId'], booleans: ['notified'] }, + outputBaskets: { name: 'output_baskets', keys: ['basketId'], booleans: ['isDeleted'] }, + transactions: { name: 'transactions', keys: ['transactionId'], booleans: ['isOutgoing'] }, + commissions: { name: 'commissions', keys: ['commissionId'], booleans: ['isRedeemed'] }, + outputs: { name: 'outputs', keys: ['outputId'], booleans: ['spendable', 'change'] }, + outputTags: { name: 'output_tags', keys: ['outputTagId'], booleans: ['isDeleted'] }, + outputTagMaps: { name: 'output_tags_map', keys: ['outputId', 'outputTagId'], booleans: ['isDeleted'] }, + txLabels: { name: 'tx_labels', keys: ['txLabelId'], booleans: ['isDeleted'] }, + txLabelMaps: { name: 'tx_labels_map', keys: ['transactionId', 'txLabelId'], booleans: ['isDeleted'] }, + certificates: { name: 'certificates', keys: ['certificateId'], booleans: ['isDeleted'] }, + certificateFields: { name: 'certificate_fields', keys: ['certificateId', 'fieldName'] }, + syncStates: { name: 'sync_states', keys: ['syncStateId'], booleans: ['init'], dates: ['when'] } +} +export interface Brc38KnexSourceOptions extends WalletReadSnapshotOptions { + maximumPageRows: number + maximumPageBytes: number + maximumRowAllocationBytes: number + /** Allocation charge of detached rows retained for one locale-sorted certificate. */ + maximumCertificateGroupBytes: number + maximumMetadataAllocationBytes?: number +} +function bound(value: number, maximum: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) + throw new RangeError(`${name} must be an integer from 1 to ${maximum}`) +} +function definition(table: WalletSnapshotTable): Definition { + if (!Object.hasOwn(definitions, table)) throw new TypeError('Unknown BRC-38 source table') + return definitions[table] +} +async function columns(k: Knex, name: string, mysql: boolean): Promise { + if (mysql) { + const [rows]: Array> = await k.raw( + 'SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION LIMIT 65', + [name] + ) + return rows.map(row => row.name) + } + const rows: Array<{ name: string }> = await k.raw('SELECT name FROM pragma_table_info(?) LIMIT 65', [name]) + return rows.map(row => row.name) +} +function size(k: Knex, schema: Definition, fields: readonly string[], mysql: boolean): Knex.Raw { + const length = mysql ? 'octet_length(??)' : 'length(cast(?? as blob))' + const cell = `64 + 2 * coalesce(${length}, 0)` + return k.raw(`(${fields.map(() => cell).join(' + ')}) as ??`, [ + ...fields.map(field => `${schema.name}.${field}`), + '__portableBytes' + ]) +} +function seek(query: Knex.QueryBuilder, keys: readonly string[], after: readonly (number | string)[]): void { + void query.where(function () { + for (let index = 0; index < keys.length; index++) { + void this.orWhere(function () { + for (let preceding = 0; preceding < index; preceding++) void this.where(keys[preceding], after[preceding]) + void this.where(keys[index], '>', after[index]) + }) + } + }) +} +function sourceKey(value: unknown, key: string): { value: number | string } { + if (key === 'fieldName') { + if (typeof value !== 'string' || value.length > 200 || Array.from(value).length > 100) + throw new TypeError('Invalid BRC-38 source field name') + return { value } + } + if (typeof value !== 'number' || !Number.isSafeInteger(value)) + throw new TypeError('Invalid BRC-38 source numeric key') + return { value } +} +function position(row: Record, keys: readonly string[]): Array { + return keys.map(key => sourceKey(row[key], key).value) +} +function selectedPrefix(candidates: Array>, maximum: number): number { + let bytes = 0, + count = 0 + for (const row of candidates) { + const charged = Number(row.__portableBytes) + if (!Number.isSafeInteger(charged) || charged < 0) throw new TypeError('Invalid BRC-38 stored row charge') + if (charged > maximum - bytes) { + if (count === 0) throw new SnapshotResourceLimitError('BRC-38 source row exceeds its page allocation policy') + break + } + bytes += charged + count++ + } + return count +} +function normalized(storage: StorageKnex, schema: Definition, raw: Record): Record { + const result: Record = Object.create(null) + for (const key of Object.keys(raw)) { + const value = raw[key] + if (schema.booleans?.includes(key)) { + if (value !== undefined) result[key] = value !== 0 && value !== null && value !== false + continue + } + if (value == null) continue + if (key === 'created_at' || key === 'updated_at' || schema.dates?.includes(key)) + result[key] = storage.validateDate(value as Date) + else result[key] = value + } + return result +} +interface PageContext { + storage: StorageKnex + userId: number + fields: Map + options: Readonly +} +async function page(context: PageContext, k: Knex, table: WalletSnapshotTable, after?: readonly (number | string)[]) { + const { storage, userId, fields, options } = context, + schema = definition(table), + mysql = storage.dbtype === 'MySQL' + let names = fields.get(table) + if (names === undefined) { + names = await columns(k, schema.name, mysql) + if (names.length < 1 || names.length > 64) throw new TypeError('Unsupported BRC-38 source schema') + fields.set(table, names) + } + const keys = schema.keys.map(key => `${schema.name}.${key}`) + const query = () => { + const selected = walletSnapshotSourceQuery(k, table, userId) + if (after !== undefined) seek(selected, keys, after) + for (const key of keys) void selected.orderBy(key) + return selected + } + // Field-name payloads are bounded in SQL before reaching the driver, just + // like blob/history lengths. No whole-table row or ID map is materialized. + const fieldLength = mysql ? 'octet_length(??)' : 'length(cast(?? as blob))' + const keyColumns = schema.keys.map(key => + key === 'fieldName' + ? k.raw(`case when ${fieldLength} <= 400 then ?? end as ??`, [ + `${schema.name}.${key}`, + `${schema.name}.${key}`, + key + ]) + : `${schema.name}.${key}` + ) + const candidates: Array> = await query() + .select(...keyColumns, size(k, schema, names, mysql)) + .limit(options.maximumPageRows) + const count = selectedPrefix(candidates, options.maximumPageBytes) + if (count === 0) return { rows: [] as SourceRow[], after: undefined, done: true } + const last = position(candidates[count - 1], schema.keys) + const payload = query().select(`${schema.name}.*`).limit(count) + const sqliteOrdinal = table === 'certificateFields' && !mysql + if (sqliteOrdinal) { + if (names.some(name => ['_rowid_', '__portableordinal'].includes(name.toLowerCase()))) + throw new TypeError('Unsupported BRC-38 certificate source ordinal schema') + // The original unpaged SQLite finder retains rowid scan order for locale + // ties. Keep its exact signed 64-bit ordinal private, as text before it + // crosses the driver; never place this key in portable archive bytes. + void payload.select(k.raw('cast(?? as text) as ??', [`${schema.name}._rowid_`, '__portableOrdinal'])) + } + const raw: Array> = await payload + if (raw.length !== count) throw new Error('BRC-38 retained source prefix changed') + const rows = raw.map(value => { + let ordinal: bigint | undefined + if (sqliteOrdinal) { + const text = value.__portableOrdinal + if (typeof text !== 'string' || !/^-?\d{1,19}$/.test(text)) + throw new TypeError('Invalid BRC-38 certificate source ordinal') + ordinal = BigInt(text) + delete value.__portableOrdinal + } + const row = projectBrc38PackedRow(table, normalized(storage, schema, value), { + maximumAllocationBytes: options.maximumRowAllocationBytes, + signal: options.signal + }) + return { row, ordinal } + }) + return { rows, after: last, done: count === candidates.length && candidates.length < options.maximumPageRows } +} +function rowCharge(value: Row): number { + // Certificate-field rows contain only scalars in the native table. Refuse a + // schema extension here until its bounded grouping contract is reviewed. + let bytes = 64 + for (const [key, child] of Object.entries(value)) { + bytes += 64 + 2 * key.length + if (typeof child === 'string') bytes += 64 + 2 * child.length + else if (typeof child === 'number' || typeof child === 'boolean') bytes += 64 + else throw new TypeError('Unsupported BRC-38 certificate-field grouping value') + } + return bytes +} +function certificateOrder(first: SourceRow, second: SourceRow): number { + if (typeof first.row.fieldName !== 'string' || typeof second.row.fieldName !== 'string') + throw new TypeError('Invalid certificate field name') + const compared = first.row.fieldName.localeCompare(second.row.fieldName) + if (compared !== 0 || first.ordinal === undefined || second.ordinal === undefined) return compared + if (first.ordinal < second.ordinal) return -1 + if (first.ordinal > second.ordinal) return 1 + return 0 +} +async function* grouped(rows: AsyncIterable, maximum: number): AsyncGenerator { + let id: unknown, + bytes = 0, + group: SourceRow[] = [] + function* ordered() { + group.sort(certificateOrder) + for (const source of group) yield source.row + } + for await (const source of rows) { + const row = source.row + if (group.length > 0 && row.certificateId !== id) { + yield* ordered() + group = [] + bytes = 0 + } + id = row.certificateId + const charge = 128 + rowCharge(row) + if (charge > maximum - bytes) + throw new SnapshotResourceLimitError('BRC-38 certificate group exceeds its allocation policy') + bytes += charge + group.push(source) + } + yield* ordered() +} +/** Coherent SQL source. Supply a dedicated reader provider so an idle + * file consumer does not occupy the foreground/writer pool. All metadata, + * closure checks and thirteen keyset streams own the same retained transaction. + * Portable order may need different SQL indexes; no performance or capability + * claim is made by constructing this source. SQLite locale ties retain the + * original rowid order; other provider collation still requires qualification. */ +export async function openBrc38KnexSource( + storage: StorageKnex, + identityKey: string, + selected: Brc38KnexSourceOptions +): Promise { + const options = Object.freeze({ ...selected }) + bound(options.maximumPageRows, 1000, 'maximumPageRows') + bound(options.maximumPageBytes, 16777216, 'maximumPageBytes') + bound(options.maximumRowAllocationBytes, 16777216, 'maximumRowAllocationBytes') + bound(options.maximumCertificateGroupBytes, 16777216, 'maximumCertificateGroupBytes') + bound(options.maximumMetadataAllocationBytes ?? 65536, 65536, 'maximumMetadataAllocationBytes') + if (!/^(02|03)[0-9a-fA-F]{64}$/.test(identityKey)) throw new TypeError('Compressed profile identity required') + options.signal?.throwIfAborted() + const view: RetainedReadSnapshot = await storage.openReadSnapshot(options) + let closed: Promise | undefined + const release = () => { + closed ??= Promise.resolve().then(() => view.close()) + return closed + } + try { + const header = await view.read(trx => readKnexSnapshotArchiveHeader(storage, identityKey, storage.toDb(trx))) + const userId = header.header.user.userId + // Closure uses the exact same direct profile predicates as the portable + // reader; auxiliary-index membership cannot conceal an exported orphan. + const closure = () => view.read(trx => assertKnexSnapshotArchiveClosure(storage.toDb(trx), userId)) + await closure() + const metadataPolicy = { + maximumAllocationBytes: options.maximumMetadataAllocationBytes ?? 65536, + signal: options.signal + } + const sourceStorage = projectBrc38PackedRow('sourceStorage', header.header.sourceStorage, metadataPolicy) + const user = projectBrc38PackedRow('user', header.header.user, metadataPolicy) + if (user.identityKey !== identityKey || sourceStorage.chain !== storage.chain) + throw new Error('BRC-38 source profile or network changed') + const context: PageContext = { storage, userId, fields: new Map(), options } + const completed = new Set() + let reading = false + async function* rawRows(table: WalletSnapshotTable) { + let after: readonly (number | string)[] | undefined, + done = false + const pages = { + [Symbol.asyncIterator]() { + return this + }, + async next() { + if (done) return { done: true as const, value: undefined } + options.signal?.throwIfAborted() + const result = await view.read(trx => page(context, storage.toDb(trx), table, after)) + done = result.done + if (!done && result.after === undefined) throw new Error('BRC-38 source failed to advance') + after = result.after + return { done: false as const, value: result } + } + } + for await (const result of pages) yield* result.rows + } + return Object.freeze({ + sourceStorage, + user, + async *rows(table: WalletSnapshotTable) { + definition(table) + if (reading || completed.has(table)) throw new Error('BRC-38 source requires one complete read of each table') + reading = true + try { + const rows = rawRows(table) + if (table === 'certificateFields') yield* grouped(rows, options.maximumCertificateGroupBytes) + else for await (const source of rows) yield source.row + completed.add(table) + } finally { + reading = false + } + }, + async validateCompleted() { + if (reading || completed.size !== 13) throw new Error('BRC-38 source tables did not complete') + options.signal?.throwIfAborted() + await closure() + }, + release + }) + } catch (error) { + try { + await release() + } catch (cleanup) { + if (cleanup === error) throw error + throw new AggregateError([error, cleanup], 'BRC-38 source opening and physical cleanup failed', { cause: error }) + } + throw error + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.test.ts new file mode 100644 index 000000000..cd7dd7551 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.test.ts @@ -0,0 +1,278 @@ +import 'fake-indexeddb/auto' +import { randomUUID } from 'node:crypto' +import { runInNewContext } from 'node:vm' +import fc from 'fast-check' +import { Utils } from '@bsv/sdk' +import { projectBrc38PackedRow } from './Brc38PackedRow' +import { canonicalPortableChunks } from './CanonicalPortableChunks' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' +import { StorageIdb } from '../StorageIdb' +import { StorageProvider } from '../StorageProvider' +import { exportBRC38 } from './index' + +const MIN_PROPERTY_RUNS = 300 +fc.configureGlobal({ + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true +}) + +const policy = { maximumAllocationBytes: 1048576 } +const iso = '2026-10-03T00:00:00.000Z' +const date = new Date(iso) +function project(row: unknown) { + return projectBrc38PackedRow('provenTxReqs', row, policy) +} +function oneHoleArray(): unknown[] { + const array: unknown[] = [] + array.length = 1 + return array +} +afterEach(() => jest.restoreAllMocks()) + +test.each([0, 1, 2, 3, 3071, 3072, 3073, 6144, 6145])( + 'native binary matches the independent SDK base64 oracle (%i)', + size => { + const value = Uint8Array.from({ length: size }, (_, index) => index % 256) + expect(project({ rawTx: value }).rawTx).toBe(Utils.toBase64(Array.from(value))) + expect(project({ rawTx: Array.from(value) }).rawTx).toBe(Utils.toBase64(Array.from(value))) + } +) + +test('native binary never expands more than one 3072-byte window', () => { + const native = new Uint8Array(3072 * 4 + 1).fill(173) + const encode = jest.spyOn(Utils, 'toBase64') + const result = project({ rawTx: native }) + expect(encode.mock.calls.map(call => call[0].length)).toEqual([3072, 3072, 3072, 3072, 1]) + expect(result.rawTx).toBe(Buffer.from(native).toString('base64')) +}) + +test('historical optional object properties omit only absent values and retain array positions and falsy values', () => { + const input = { + history: { notes: [{ what: 'broadcast', when: null, code: 0, accepted: false, detail: '' }] }, + notify: JSON.stringify({ transactionIds: null, enabled: false }), + rawTx: new Uint8Array([0, 255]), + created_at: date, + updated_at: date, + batch: null, + logger: undefined + } + const historyBefore = JSON.stringify(input.history) + const result = project(input) + expect(result).toEqual({ + history: { notes: [{ what: 'broadcast', code: 0, accepted: false, detail: '' }] }, + notify: { enabled: false }, + rawTx: 'AP8=', + created_at: iso, + updated_at: iso + }) + expect(JSON.stringify(input.history)).toBe(historyBefore) + input.history.notes[0].what = 'later' + input.rawTx.fill(1) + expect(result.history).toEqual({ notes: [{ what: 'broadcast', code: 0, accepted: false, detail: '' }] }) + expect(result.rawTx).toBe('AP8=') +}) + +test('sync state nullable history paths preserve source checkpoints and meaningful values', () => { + const row = { + syncMap: { transaction: { maxUpdated_at: null, count: 0, idMap: { '1': 7 } } }, + errorLocal: '{"message":"failure","stack":null}', + errorOther: { message: '', stack: undefined }, + init: false, + when: date, + storageIdentityKey: 'original' + } + expect(projectBrc38PackedRow('syncStates', row, policy)).toEqual({ + syncMap: { transaction: { count: 0, idMap: { '1': 7 } } }, + errorLocal: { message: 'failure' }, + errorOther: { message: '' }, + init: false, + when: iso, + storageIdentityKey: 'original' + }) +}) + +test.each([ + { history: { notes: [null] } }, + { history: { notes: [{ what: null }] } }, + { history: { other: null } }, + { notify: { transactionIds: [null] } }, + { history: 'null' }, + { history: '[]' }, + { history: '{"notes":[null]}' }, + { rawTx: [NaN] }, + { rawTx: [-1] }, + { rawTx: [256] }, + { rawTx: oneHoleArray() }, + { value: Infinity }, + { value: new Map() }, + { value: '\ud800' } +])('meaningful invalid values refuse without silently altering the archive (%#)', row => { + expect(() => project(row)).toThrow(TypeError) +}) + +test('accessors, absent array entries, cycles and over-depth objects refuse before output', () => { + const getter = jest.fn(() => 'value') + const row = Object.defineProperty({}, 'history', { enumerable: true, get: getter }) + expect(() => project(row)).toThrow(TypeError) + expect(getter).not.toHaveBeenCalled() + const cycle: Record = {} + cycle.child = cycle + expect(() => project({ history: cycle })).toThrow(TypeError) + expect(() => project({ history: { notes: oneHoleArray() } })).toThrow(TypeError) + let deep: unknown = 'leaf' + for (let depth = 0; depth < 65; depth++) deep = { child: deep } + expect(() => project({ history: deep })).toThrow(SnapshotResourceLimitError) +}) + +test('structured JSON admission rejects size, node and depth overflow before JSON.parse', () => { + const parse = jest.spyOn(JSON, 'parse') + const bounded = (history: string) => + projectBrc38PackedRow('provenTxReqs', { history }, { maximumAllocationBytes: 1024 }) + expect(() => bounded('{"value":"' + 'a'.repeat(1024) + '"}')).toThrow(SnapshotResourceLimitError) + expect(() => bounded('{"value":[' + Array(30).fill('0').join(',') + ']}')).toThrow(SnapshotResourceLimitError) + expect(parse).not.toHaveBeenCalled() + expect(() => project({ history: '{"child":'.repeat(65) + '0' + '}'.repeat(65) })).toThrow(SnapshotResourceLimitError) + expect(parse).not.toHaveBeenCalled() +}) + +test.each(['{', '{"a":}', '{"a":1,}', '{"a":"\\x"}'])( + 'JSON syntax is independently rejected after bounded admission (%s)', + history => { + expect(() => project({ history })).toThrow(SyntaxError) + } +) + +test('escaped structural characters do not alter admission nesting or parsed content', () => { + const value = { text: '[[[{{{\\"\n\t🙂', nested: { valid: true }, escaped: '\\' } + expect(project({ history: JSON.stringify(value) }).history).toEqual(value) +}) + +test.each([0, -1, NaN, Infinity, 1.5, 16777217])( + 'allocation policy rejects invalid values (%s)', + maximumAllocationBytes => { + expect(() => projectBrc38PackedRow('user', {}, { maximumAllocationBytes })).toThrow(RangeError) + } +) + +test('binary byte policy refuses before base64 allocation and cancellation retains its exact reason', () => { + const encode = jest.spyOn(Utils, 'toBase64') + expect(() => + projectBrc38PackedRow('outputs', { lockingScript: new Uint8Array(1000) }, { maximumAllocationBytes: 256 }) + ).toThrow(SnapshotResourceLimitError) + expect(encode).not.toHaveBeenCalled() + const controller = new AbortController(), + reason = new Error('cancel projection') + controller.abort(reason) + expect(() => projectBrc38PackedRow('user', {}, { ...policy, signal: controller.signal })).toThrow(reason) +}) + +test('certificate embedded field views are omitted while separate standard field rows retain original values', () => { + expect( + projectBrc38PackedRow('certificates', { certificateId: 19, fields: { name: 'private expanded view' } }, policy) + ).toEqual({ certificateId: 19 }) + expect( + projectBrc38PackedRow('certificateFields', { certificateId: 19, fieldName: 'name', fieldValue: 'original' }, policy) + ).toEqual({ certificateId: 19, fieldName: 'name', fieldValue: 'original' }) +}) + +test('real IndexedDB source projection matches the original coherent legacy exporter', async () => { + const storage = new StorageIdb(StorageProvider.createStorageBaseOptions('test')) + storage.dbName = 'packed-portable-' + randomUUID() + try { + await storage.migrate('original source', 'source-storage') + await storage.makeAvailable() + const identity = '02' + '11'.repeat(32) + const { user } = await storage.findOrInsertUser(identity) + await storage.insertTransaction({ + created_at: date, + updated_at: date, + transactionId: 0, + userId: user.userId, + status: 'unproven', + reference: 'original', + isOutgoing: false, + satoshis: 0, + description: '', + txid: 'a'.repeat(64), + rawTx: [0, 255] + }) + await storage.insertProvenTxReq({ + created_at: date, + updated_at: date, + provenTxReqId: 0, + status: 'unmined', + attempts: 0, + notified: false, + txid: 'a'.repeat(64), + history: '{"notes":[{"what":"broadcast","when":null,"code":0}]}', + notify: '{"transactionIds":null}', + rawTx: [0, 255] + }) + const legacy = await exportBRC38(storage, identity, { requireSnapshot: true }) + expect(projectBrc38PackedRow('sourceStorage', await storage.readSettings(), policy)).toEqual(legacy.sourceStorage) + expect(projectBrc38PackedRow('user', user, policy)).toEqual(legacy.user) + const [transaction] = await storage.findTransactions({ partial: { userId: user.userId } }) + const [request] = await storage.getProvenTxReqsForUser({ userId: user.userId }) + expect( + projectBrc38PackedRow('transactions', { ...transaction, rawTx: Uint8Array.from(transaction.rawTx!) }, policy) + ).toEqual(legacy.tables.transactions[0]) + expect(project({ ...request, rawTx: Uint8Array.from(request.rawTx) })).toEqual(legacy.tables.provenTxReqs[0]) + } finally { + await storage.destroy() + await storage.dropAllData() + } +}) + +test('generated binary and structured histories retain exact bytes, falsy values and bounded canonical output', () => { + fc.assert( + fc.property(fc.uint8Array({ maxLength: 8192 }), fc.integer(), fc.boolean(), (rawTx, code, accepted) => { + const row = project({ + rawTx, + history: JSON.stringify({ notes: [{ what: 'original', detail: null, code, accepted }] }) + }) + expect(row.rawTx).toBe(Buffer.from(rawTx).toString('base64')) + expect(row.history).toEqual({ notes: [{ what: 'original', code, accepted }] }) + const chunks = [...canonicalPortableChunks(row, { maximumValueBytes: 1048576, maximumChunkBytes: 64 })] + expect(chunks.every(chunk => chunk.length <= 64)).toBe(true) + expect(JSON.parse(Buffer.concat(chunks).toString())).toEqual(row) + }), + { + numRuns: Math.max(300, Number(process.env.FAST_CHECK_NUM_RUNS ?? 300)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) + +test('plain data rows from another realm normalize while class and accessor values refuse', () => { + const row: unknown = runInNewContext('({history:{notes:[{what:"original",when:null}]}})') + expect(project(row)).toEqual({ history: { notes: [{ what: 'original' }] } }) + class Custom { + value = 'non-plain' + } + expect(() => project({ history: new Custom() })).toThrow(TypeError) +}) + +test('non-base64 legacy numeric columns keep their original JSON array shape within the row budget', () => { + expect(projectBrc38PackedRow('transactions', { noSendExpiryReclaimRawTx: new Uint8Array([0, 255]) }, policy)).toEqual( + { noSendExpiryReclaimRawTx: [0, 255] } + ) + expect(() => + projectBrc38PackedRow( + 'transactions', + { noSendExpiryReclaimRawTx: new Uint8Array(10) }, + { maximumAllocationBytes: 256 } + ) + ).toThrow(SnapshotResourceLimitError) +}) + +test('native Date branding preserves timestamps across realms and still refuses invalid dates', () => { + const value: unknown = runInNewContext('new Date("2026-10-03T00:00:00.000Z")') + expect(project({ created_at: value })).toEqual({ created_at: iso }) + expect(() => project({ created_at: new Date(NaN) })).toThrow(RangeError) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.ts new file mode 100644 index 000000000..b01e837af --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.ts @@ -0,0 +1,244 @@ +import { Utils } from '@bsv/sdk' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' +import type { BRC38Tables } from './index' + +type Value = string | number | boolean | Value[] | { [key: string]: Value } +type Kind = keyof BRC38Tables | 'sourceStorage' | 'user' +export interface Brc38PackedRowOptions { + /** Combined conservative charge for stored structured JSON, parsed nodes and + * the detached portable row. This is not encoded size or process RSS. */ + maximumAllocationBytes: number + signal?: AbortSignal +} +interface Detached { + value: Value +} +interface Lexical { + quoted: boolean + escaped: boolean + atom: boolean + depth: number +} +interface State { + remaining: number + active: Set + signal?: AbortSignal +} +const binary: Partial> = { + commissions: ['lockingScript'], + outputs: ['lockingScript'], + provenTxs: ['merklePath', 'rawTx'], + provenTxReqs: ['rawTx', 'inputBEEF'], + transactions: ['inputBEEF', 'rawTx'] +} +const structured: Partial> = { + provenTxReqs: ['history', 'notify'], + syncStates: ['syncMap', 'errorLocal', 'errorOther'] +} +const entities = new Set([ + 'provenTx', + 'outputBasket', + 'outputTag', + 'txLabel', + 'transaction', + 'output', + 'txLabelMap', + 'outputTagMap', + 'certificate', + 'certificateField', + 'commission', + 'provenTxReq' +]) +function invalid(): never { + throw new TypeError('BRC-38 packed rows require plain data properties and finite portable values') +} +function charge(state: State, bytes: number): void { + state.signal?.throwIfAborted() + if (bytes > state.remaining) throw new SnapshotResourceLimitError('BRC-38 row exceeds its allocation policy') + state.remaining -= bytes +} +function text(value: string, state: State): string { + charge(state, 64 + 2 * value.length) + for (let index = 0; index < value.length; index++) { + if (index % 1024 === 0) state.signal?.throwIfAborted() + const unit = value.charCodeAt(index) + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++index) + if (!(next >= 0xdc00 && next <= 0xdfff)) invalid() + } else if (unit >= 0xdc00 && unit <= 0xdfff) invalid() + } + return value +} +function data(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor === undefined || !Object.hasOwn(descriptor, 'value')) invalid() + return descriptor.value +} +function plain(value: object): void { + const prototype: unknown = Object.getPrototypeOf(value) + if (prototype !== null && (typeof prototype !== 'object' || Object.getPrototypeOf(prototype) !== null)) invalid() +} +function optional(field: string, path: readonly (string | number)[]): boolean { + if (field === 'history') + return ( + (path.length === 1 && path[0] === 'notes') || + (path.length === 3 && path[0] === 'notes' && typeof path[1] === 'number' && path[2] !== 'what') + ) + if (field === 'notify') return path.length === 1 && path[0] === 'transactionIds' + if (field === 'syncMap') return path.length === 2 && entities.has(String(path[0])) && path[1] === 'maxUpdated_at' + return (field === 'errorLocal' || field === 'errorOther') && path.length === 1 && path[0] === 'stack' +} +function objectValue(value: object, state: State, field: string, path: readonly (string | number)[], depth: number) { + plain(value) + const result: { [key: string]: Value } = Object.create(null) + for (const key in value) { + if (!Object.hasOwn(value, key)) continue + text(key, state) + const child = data(value, key), + childPath = [...path, key] + if (child == null && optional(field, childPath)) continue + result[key] = detached(child, state, field, childPath, depth + 1).value + } + return result +} +function arrayValue(value: unknown[], state: State, field: string, path: readonly (string | number)[], depth: number) { + if (value.length > Math.floor(state.remaining / 64)) + throw new SnapshotResourceLimitError('BRC-38 array exceeds its allocation policy') + const result: Value[] = [] + for (let index = 0; index < value.length; index++) + result.push(detached(data(value, String(index)), state, field, [...path, index], depth + 1).value) + return result +} +function detached( + value: unknown, + state: State, + field: string, + path: readonly (string | number)[], + depth: number +): Detached { + if (depth > 64) throw new SnapshotResourceLimitError('BRC-38 row nesting exceeds 64 levels') + if (typeof value === 'string') return { value: text(value, state) } + charge(state, 64) + if (typeof value === 'boolean') return { value } + if (typeof value === 'number' && Number.isFinite(value)) return { value } + if (value === null || typeof value !== 'object' || state.active.has(value)) invalid() + state.active.add(value) + try { + return { + value: Array.isArray(value) + ? arrayValue(value, state, field, path, depth) + : objectValue(value, state, field, path, depth) + } + } finally { + state.active.delete(value) + } +} +/** Admission before JSON.parse: raw UTF-16 size, each possible parsed node and + * nesting are bounded before the parser can allocate. This scanner is not a + * syntax validator; JSON.parse still rejects every malformed input. */ +function structural(unit: string, lexical: Lexical, state: State): void { + if (unit === '"') { + charge(state, 64) + lexical.quoted = true + lexical.atom = false + } else if (unit === '[' || unit === '{') { + charge(state, 64) + lexical.depth++ + lexical.atom = false + if (lexical.depth > 64) throw new SnapshotResourceLimitError('BRC-38 stored JSON nesting exceeds 64 levels') + } else if (unit === ']' || unit === '}') { + lexical.depth-- + lexical.atom = false + } else if (unit === ',' || unit === ':' || /\s/.test(unit)) lexical.atom = false + else if (!lexical.atom) { + charge(state, 64) + lexical.atom = true + } +} +function parsed(value: string, state: State): unknown { + charge(state, 64 + 2 * value.length) + const lexical: Lexical = { quoted: false, escaped: false, atom: false, depth: 1 } + for (let index = 0; index < value.length; index++) { + if (index % 1024 === 0) state.signal?.throwIfAborted() + const unit = value[index] + if (!lexical.quoted) structural(unit, lexical, state) + else if (lexical.escaped) lexical.escaped = false + else if (unit === '\\') lexical.escaped = true + else if (unit === '"') lexical.quoted = false + } + state.signal?.throwIfAborted() + return JSON.parse(value) +} +function base64(value: unknown, state: State): string { + if (!(value instanceof Uint8Array) && !Array.isArray(value)) invalid() + const length = value.length + charge(state, 64 + 8 * Math.ceil(length / 3)) + const parts: string[] = [] + for (let offset = 0; offset < length; offset += 3072) { + state.signal?.throwIfAborted() + const window: number[] = [] + for (let index = offset; index < Math.min(length, offset + 3072); index++) { + const byte = value instanceof Uint8Array ? value[index] : data(value, String(index)) + if (typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255) invalid() + window.push(byte) + } + parts.push(Utils.toBase64(window)) + } + return parts.join('') +} +function dateValue(value: unknown): boolean { + try { + Date.prototype.getTime.call(value) + return true + } catch { + return false + } +} +function fieldValue(kind: Kind, key: string, value: unknown, state: State): Detached { + if (binary[kind]?.includes(key)) return { value: base64(value, state) } + if (structured[kind]?.includes(key)) { + const source = typeof value === 'string' ? parsed(value, state) : value + if (source === null || typeof source !== 'object' || Array.isArray(source)) invalid() + return detached(source, state, key, [], 1) + } + if (dateValue(value)) return { value: text(Date.prototype.toISOString.call(value), state) } + // PackedSnapshotRow also retains legacy number arrays that are not standard + // base64 columns. Expand only this bounded row, preserving their JSON shape. + if (value instanceof Uint8Array) { + charge(state, 64 + 64 * value.length) + const numbers: Value[] = [] + for (const byte of value) { + state.signal?.throwIfAborted() + numbers.push(byte) + } + return { value: numbers } + } + return detached(value, state, '', [], 1) +} +/** Bounded projection for ONE captured packed row. Native binary uses + * 3,072-byte base64 windows; no complete number[] expansion is constructed. + * Exact legacy optional-object omissions are retained without mutating source. + * This does not establish coherent capture, table ordering or relational closure. */ +export function projectBrc38PackedRow( + kind: Kind, + row: unknown, + options: Brc38PackedRowOptions +): BRC38Tables[Kind & keyof BRC38Tables][number] { + const maximum = options.maximumAllocationBytes + if (!Number.isSafeInteger(maximum) || maximum < 1 || maximum > 16777216) + throw new RangeError('maximumAllocationBytes must be an integer from 1 to 16777216') + if (row === null || typeof row !== 'object' || Array.isArray(row)) invalid() + plain(row) + const state: State = { remaining: maximum, active: new Set([row]), signal: options.signal } + charge(state, 64) + const result: { [key: string]: Value } = Object.create(null) + for (const key in row) { + if (!Object.hasOwn(row, key)) continue + if (key === 'logger' || (kind === 'certificates' && key === 'fields')) continue + text(key, state) + const value = data(row, key) + if (value == null) continue + result[key] = fieldValue(kind, key, value, state).value + } + return result +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts new file mode 100644 index 000000000..874a9ca5b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts @@ -0,0 +1,327 @@ +import { createDecipheriv } from 'node:crypto' +import fc from 'fast-check' +import { createBrc38Stream, type Brc38StreamSource, type Brc38StreamOptions } from './Brc38Stream' +import { Brc39StreamFrame } from './Brc39Frame' +import { encryptBRC39, parseBRC38Json, type BRC38WalletData, type BRC38Tables } from './index' +import { encryptBrc39StreamToQuarantine } from './Brc39StreamNode' +import { + registerArgon2idBackend, + unregisterArgon2idBackend, + type AsyncArgon2idBackend +} from '../../utility/Argon2idBackend' + +const MIN_PROPERTY_RUNS = 300 +fc.configureGlobal({ + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true +}) + +const iso = '2026-10-03T00:00:00.000Z' +const options: Brc38StreamOptions = { + exportedAt: iso, + maximumArchiveBytes: 1048576, + maximumRowBytes: 65536, + maximumChunkBytes: 64 +} +let backend: AsyncArgon2idBackend | undefined +afterEach(() => { + if (backend !== undefined) unregisterArgon2idBackend(backend) + backend = undefined +}) +function document(): BRC38WalletData { + const times = { created_at: iso, updated_at: iso } + return { + brc: 38, + title: 'User Wallet Data Format', + formatVersion: 1, + exportedAt: iso, + sourceStorage: { ...times, storageIdentityKey: 'original-source', storageName: 'original source', chain: 'test' }, + user: { ...times, userId: 7, identityKey: 'original-identity', activeStorage: 'original-source' }, + tables: { + provenTxs: [{ ...times, provenTxId: 11, txid: 'a'.repeat(64), rawTx: 'AQI=', merklePath: 'AwQ=' }], + provenTxReqs: [{ ...times, provenTxReqId: 12, txid: 'a'.repeat(64), provenTxId: 11 }], + outputBaskets: [{ ...times, basketId: 13, userId: 7, name: 'default', isDeleted: false }], + transactions: [{ ...times, transactionId: 14, userId: 7, txid: 'a'.repeat(64), provenTxId: 11 }], + commissions: [{ ...times, commissionId: 15, userId: 7, transactionId: 14 }], + outputs: [ + { ...times, outputId: 16, userId: 7, transactionId: 14, basketId: 13, spentBy: 14, lockingScript: 'AQI=' } + ], + outputTags: [{ ...times, outputTagId: 17, userId: 7, tag: 'tag \u{1f642}', isDeleted: true }], + outputTagMaps: [{ ...times, outputId: 16, outputTagId: 17 }], + txLabels: [{ ...times, txLabelId: 18, userId: 7, label: 'label', isDeleted: false }], + txLabelMaps: [{ ...times, transactionId: 14, txLabelId: 18 }], + certificates: [{ ...times, certificateId: 19, userId: 7 }], + certificateFields: [{ ...times, certificateId: 19, userId: 7, fieldName: 'name', fieldValue: 'value' }], + syncStates: [{ ...times, syncStateId: 20, userId: 7, storageIdentityKey: 'original-source', syncMap: {} }] + } + } +} +function source( + data = document() +): Brc38StreamSource & { visits: Array; released: number; validated: number } { + const current = { + sourceStorage: data.sourceStorage, + user: data.user, + visits: [] as Array, + released: 0, + validated: 0, + async *rows(table: keyof BRC38Tables) { + current.visits.push(table) + for (const row of data.tables[table]) yield row + }, + async validateCompleted() { + expect(parseBRC38Json(JSON.stringify(data))).toEqual(data) + current.validated++ + }, + async release() { + current.released++ + } + } + return current +} +async function collect(chunks: AsyncIterable): Promise { + const parts: Uint8Array[] = [] + for await (const bytes of chunks) { + expect(bytes.length).toBeLessThanOrEqual(64) + parts.push(bytes) + } + // Complete materialization exists only in the bounded synthetic test oracle. + return Buffer.concat(parts) +} +function fixedBackend(): Uint8Array { + const key = new Uint8Array(32).fill(17) + backend = { preload: async () => {}, isReady: () => true, deriveKey: async () => key.slice() } + registerArgon2idBackend(backend) + return key +} + +test('all thirteen tables match exact plaintext bytes from the independent legacy encrypted exporter', async () => { + const key = fixedBackend() + const data = document() + const owner = source(data) + const stream = await createBrc38Stream(owner, options) + const bytes = await collect(stream.chunks) + expect(parseBRC38Json(bytes.toString())).toEqual(data) + await stream.validateCompleted() + await stream.close() + expect(owner.released).toBe(1) + expect(owner.validated).toBe(1) + expect(owner.visits).toEqual([ + 'certificateFields', + 'certificates', + 'commissions', + 'outputBaskets', + 'outputTagMaps', + 'outputTags', + 'outputs', + 'provenTxReqs', + 'provenTxs', + 'syncStates', + 'transactions', + 'txLabelMaps', + 'txLabels' + ]) + const encrypted = await encryptBRC39(data, 'password') + const frame = new Brc39StreamFrame({ + maximumFileBytes: 1048576, + maximumChunkBytes: 65536, + maximumIterations: 7, + maximumMemoryKiB: 131072, + maximumParallelism: 1 + }) + const ciphertext = frame.accept(new Uint8Array(encrypted)) + const header = frame.header() + if (header === undefined) throw new Error('legacy envelope has no header') + const tag = frame.finish().tag + const cipher = createDecipheriv('aes-256-gcm', key, header.nonce, { authTagLength: 16 }) + cipher.setAuthTag(tag) + const plaintext = Buffer.concat([...ciphertext.map(chunk => cipher.update(chunk)), cipher.final()]) + expect(bytes).toEqual(plaintext) +}) + +test('source metadata detaches before the first asynchronous boundary and each row before output', async () => { + const data = document() + const original = structuredClone(data) + data.tables.outputTags[0].tag = 'A'.repeat(1024) + original.tables.outputTags[0].tag = 'A'.repeat(1024) + const owner = source(data) + // This test's independently validated fixture is the original source view. + owner.validateCompleted = async () => { + expect(parseBRC38Json(JSON.stringify(original))).toEqual(original) + } + const pending = createBrc38Stream(owner, options) + data.sourceStorage.storageName = 'later alias' + data.user.identityKey = 'later alias' + const stream = await pending + const parts: Uint8Array[] = [] + let changed = false + for await (const bytes of stream.chunks) { + parts.push(bytes) + if (!changed && Buffer.concat(parts).includes(Buffer.from('AAAA'))) { + changed = true + data.tables.outputTags[0].updated_at = '2026-10-04T00:00:00.000Z' + } + } + expect(changed).toBe(true) + expect(parseBRC38Json(Buffer.concat(parts).toString())).toEqual(original) + await stream.validateCompleted() + expect(owner.released).toBe(1) +}) + +test('no rows are prefetched while provisional header output is held', async () => { + const owner = source() + const stream = await createBrc38Stream(owner, options) + await expect(stream.validateCompleted()).rejects.toThrow('did not complete') + const first = await stream.chunks[Symbol.asyncIterator]().next() + expect(first.done).toBe(false) + expect(owner.visits).toEqual([]) + await stream.close() + await stream.close() + expect(owner.released).toBe(1) + expect(owner.validated).toBe(0) + await expect(stream.validateCompleted()).rejects.toThrow('did not complete') +}) + +test('an unused source can close without beginning row iteration', async () => { + const owner = source() + const stream = await createBrc38Stream(owner, options) + await stream.close() + expect(owner.released).toBe(1) + expect(owner.visits).toEqual([]) + await expect(stream.validateCompleted()).rejects.toThrow('did not complete') +}) + +test.each(['maximumArchiveBytes', 'maximumRowBytes', 'maximumMetadataBytes'] as const)( + 'invalid %s releases the owned source before refusal', + async name => { + const owner = source() + await expect(createBrc38Stream(owner, { ...options, [name]: 0 })).rejects.toThrow('must be an integer') + expect(owner.released).toBe(1) + expect(owner.visits).toEqual([]) + } +) + +test('archive and row limits refuse partial output and release the source', async () => { + const archive = source() + const small = await createBrc38Stream(archive, { ...options, maximumArchiveBytes: 100 }) + await expect(collect(small.chunks)).rejects.toThrow('archive exceeds') + await small.close() + expect(archive.released).toBe(1) + await expect(small.validateCompleted()).rejects.toThrow('did not complete') + const rows = source() + const bounded = await createBrc38Stream(rows, { ...options, maximumRowBytes: 1 }) + await expect(collect(bounded.chunks)).rejects.toThrow('allocation budget') + await bounded.close() + expect(rows.released).toBe(1) +}) + +test('metadata policy and malformed metadata refuse before row access', async () => { + const bounded = source() + await expect(createBrc38Stream(bounded, { ...options, maximumMetadataBytes: 1 })).rejects.toThrow('allocation budget') + expect(bounded.released).toBe(1) + const invalid = source() + await expect(createBrc38Stream(invalid, { ...options, exportedAt: 'not a date' })).rejects.toThrow('timestamp') + expect(invalid.released).toBe(1) + expect(invalid.visits).toEqual([]) +}) + +test('source processing and release failures retain both exact causes', async () => { + const owner = source() + const original = new Error('synthetic source read failure') + const cleanup = new Error('synthetic source release failure') + owner.rows = () => ({ [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(original) }) }) + owner.release = async () => { + throw cleanup + } + const stream = await createBrc38Stream(owner, options) + await expect(collect(stream.chunks)).rejects.toMatchObject({ cause: original, errors: [original, cleanup] }) + await expect(stream.close()).rejects.toBe(cleanup) + await expect(stream.validateCompleted()).rejects.toThrow('did not complete') +}) + +test('semantic completion failure prevents the encrypted tag and discards private output', async () => { + fixedBackend() + const owner = source() + const original = new Error('synthetic source closure failure') + owner.validateCompleted = async () => { + throw original + } + const stream = await createBrc38Stream(owner, options) + const output = { + parts: [] as Uint8Array[], + discarded: 0, + async appendUntrusted(bytes: Uint8Array) { + output.parts.push(bytes.slice()) + }, + async discard() { + output.parts = [] + output.discarded++ + } + } + await expect( + encryptBrc39StreamToQuarantine(stream, 'password', output, { + policy: { + maximumFileBytes: 1048576, + maximumChunkBytes: 64, + maximumIterations: 7, + maximumMemoryKiB: 131072, + maximumParallelism: 1 + }, + maximumPasswordBytes: 1024 + }) + ).rejects.toBe(original) + expect(output.parts).toEqual([]) + expect(output.discarded).toBe(1) + expect(owner.released).toBe(1) +}) + +test('cancellation releases the source and retains the exact reason', async () => { + const owner = source() + const controller = new AbortController() + const original = new Error('operator cancellation') + const stream = await createBrc38Stream(owner, { ...options, signal: controller.signal }) + controller.abort(original) + await expect(collect(stream.chunks)).rejects.toBe(original) + await stream.close() + expect(owner.released).toBe(1) +}) + +test('generated standard rows retain source IDs, tombstones, bytes and complete tables', async () => { + await fc.assert( + fc.asyncProperty(fc.array(fc.string({ maxLength: 40 }), { maxLength: 30 }), async names => { + const data = document() + data.tables.outputTags = names.map((tag, index) => ({ + created_at: iso, + updated_at: iso, + userId: 7, + outputTagId: index + 1, + tag, + isDeleted: index % 2 === 0 + })) + data.tables.outputTagMaps = [] + const owner = source(data) + const stream = await createBrc38Stream(owner, options) + try { + const bytes = await collect(stream.chunks) + expect(parseBRC38Json(bytes.toString())).toEqual(data) + await stream.validateCompleted() + } finally { + await stream.close() + } + expect(owner.visits).toHaveLength(13) + expect(owner.validated).toBe(1) + expect(owner.released).toBe(1) + }), + { + numRuns: Math.max(300, Number(process.env.FAST_CHECK_NUM_RUNS ?? 300)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + path: process.env.FAST_CHECK_PATH ?? '', + endOnFailure: true, + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.ts new file mode 100644 index 000000000..3b1e72fc1 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.ts @@ -0,0 +1,225 @@ +import { canonicalPortableChunks } from './CanonicalPortableChunks' +import { parseBRC38Json, type BRC38Tables, type BRC38WalletData } from './index' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' + +type Table = keyof BRC38Tables +type Row = BRC38Tables[Table][number] +export interface Brc38StreamSource { + sourceStorage: BRC38WalletData['sourceStorage'] + user: BRC38WalletData['user'] + /** Host-owned coherent portable rows, in the standard table's original sort + * order. Normalize dates, native binary and historical optional JSON fields + * before yielding. No replica checkpoint or staging metadata belongs here. */ + rows: (table: Table) => AsyncIterable + /** Independently verify complete schema, relational closure and original + * source provenance in the owned coherent view. The encoder checks JSON + * spelling and bounds; it cannot prove those source guarantees itself. */ + validateCompleted: () => Promise + /** Idempotent release, waiting for all owned source I/O to settle. */ + release: () => Promise +} +export interface Brc38StreamOptions { + exportedAt: string + maximumArchiveBytes: number + maximumRowBytes: number + /** Metadata alone may be materialized within this fixed bound, <=65,536. */ + maximumMetadataBytes?: number + maximumChunkBytes?: number + signal?: AbortSignal +} +export interface Brc38Stream { + chunks: AsyncIterable + validateCompleted: () => Promise + close: () => Promise +} +const tables = Object.freeze([ + 'certificateFields', + 'certificates', + 'commissions', + 'outputBaskets', + 'outputTagMaps', + 'outputTags', + 'outputs', + 'provenTxReqs', + 'provenTxs', + 'syncStates', + 'transactions', + 'txLabelMaps', + 'txLabels' +] as const satisfies readonly Table[]) + +function boundedInteger(value: number, maximum: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) + throw new RangeError(`${name} must be an integer from 1 to ${maximum}`) +} +function objectRow(value: unknown): void { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError('BRC-38 requires portable object rows') +} +function metadata(source: Brc38StreamSource, options: Readonly): BRC38WalletData { + const maximum = options.maximumMetadataBytes ?? 65536 + const empty: BRC38Tables = { + provenTxs: [], + provenTxReqs: [], + outputBaskets: [], + transactions: [], + commissions: [], + outputs: [], + outputTags: [], + outputTagMaps: [], + txLabels: [], + txLabelMaps: [], + certificates: [], + certificateFields: [], + syncStates: [] + } + const value: BRC38WalletData = { + brc: 38, + title: 'User Wallet Data Format', + formatVersion: 1, + exportedAt: options.exportedAt, + sourceStorage: source.sourceStorage, + user: source.user, + tables: empty + } + const buffer = new Uint8Array(maximum) + let used = 0 + for (const bytes of canonicalPortableChunks(value, { + maximumValueBytes: maximum, + maximumChunkBytes: options.maximumChunkBytes, + signal: options.signal + })) { + if (bytes.length > maximum - used) + throw new SnapshotResourceLimitError('BRC-38 metadata exceeds the selected byte policy') + buffer.set(bytes, used) + used += bytes.length + } + // This bounded header validation is not validation of the streamed tables. + return parseBRC38Json(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, used))) +} + +/** All-thirteen-table canonical JSON encoder, not a source adapter or + * a streaming import validator. Only bounded metadata and one detached row are + * retained. Arrays remain incrementally consumed, with original source IDs and + * the host's standard row ordering preserved. Source validation and release + * must both succeed before validateCompleted or an encryption tag can succeed. + * All chunks are private provisional output until that completion. */ +export async function createBrc38Stream(source: Brc38StreamSource, selected: Brc38StreamOptions): Promise { + const releaseSource = source.release.bind(source) + let released: Promise | undefined + const release = (): Promise => { + released ??= Promise.resolve().then(releaseSource) + return released + } + try { + const options = Object.freeze({ ...selected }) + const { signal, maximumArchiveBytes, maximumRowBytes, maximumChunkBytes = 65536 } = options + boundedInteger(maximumArchiveBytes, Number.MAX_SAFE_INTEGER, 'maximumArchiveBytes') + boundedInteger(maximumRowBytes, 16777216, 'maximumRowBytes') + boundedInteger(options.maximumMetadataBytes ?? 65536, 65536, 'maximumMetadataBytes') + if (!Number.isSafeInteger(maximumChunkBytes) || maximumChunkBytes < 64 || maximumChunkBytes > 65536) + throw new RangeError('maximumChunkBytes must be an integer from 64 to 65536') + signal?.throwIfAborted() + const header = metadata(source, options) + const rows = source.rows.bind(source) + const validate = source.validateCompleted.bind(source) + const encoder = new TextEncoder() + const rowPolicy = { maximumValueBytes: maximumRowBytes, maximumChunkBytes, signal } + const headerPolicy = { ...rowPolicy, maximumValueBytes: options.maximumMetadataBytes ?? 65536 } + let completed = false + function* literal(text: string) { + const bytes = encoder.encode(text) + for (let offset = 0; offset < bytes.length; offset += maximumChunkBytes) { + signal?.throwIfAborted() + yield bytes.slice(offset, offset + maximumChunkBytes) + } + } + async function* body() { + let failure: { error: unknown } | undefined + try { + yield* literal(`{"brc":38,"exportedAt":${JSON.stringify(header.exportedAt)},"formatVersion":1,"sourceStorage":`) + yield* canonicalPortableChunks(header.sourceStorage, headerPolicy) + yield* literal(',"tables":{') + for await (const [index, table] of tables.entries()) { + yield* literal(`${index === 0 ? '' : ','}${JSON.stringify(table)}:[`) + let first = true + for await (const row of rows(table)) { + signal?.throwIfAborted() + objectRow(row) + if (!first) yield* literal(',') + first = false + yield* canonicalPortableChunks(row, rowPolicy) + } + yield* literal(']') + } + yield* literal('},"title":"User Wallet Data Format","user":') + yield* canonicalPortableChunks(header.user, headerPolicy) + yield* literal('}') + signal?.throwIfAborted() + await validate() + signal?.throwIfAborted() + } catch (error) { + failure = { error } + } finally { + try { + await release() + } catch (cleanup) { + failure = { + error: + failure === undefined + ? cleanup + : new AggregateError([failure.error, cleanup], 'BRC-38 source processing and cleanup failed', { + cause: failure.error + }) + } + } + } + if (failure !== undefined) throw failure.error + } + async function* chunks() { + let used = 0 + for await (const bytes of body()) { + signal?.throwIfAborted() + if (bytes.length > maximumArchiveBytes - used) + throw new SnapshotResourceLimitError('BRC-38 archive exceeds the selected byte policy') + used += bytes.length + yield bytes + } + completed = true + } + const iterator = chunks() + return Object.freeze({ + chunks: iterator, + async validateCompleted() { + if (!completed) throw new Error('BRC-38 source stream did not complete') + }, + async close() { + let failure: { error: unknown } | undefined + try { + await iterator.return(undefined) + } catch (error) { + failure = { error } + } + try { + await release() + } catch (cleanup) { + if (failure === undefined) failure = { error: cleanup } + else if (failure.error !== cleanup) + failure = { + error: new AggregateError([failure.error, cleanup], 'BRC-38 iterator and source cleanup failed', { + cause: failure.error + }) + } + } + if (failure !== undefined) throw failure.error + } + }) + } catch (error) { + try { + await release() + } catch (cleanup) { + throw new AggregateError([error, cleanup], 'BRC-38 preparation and source cleanup failed', { cause: error }) + } + throw error + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.property.test.ts new file mode 100644 index 000000000..dca4199d0 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.property.test.ts @@ -0,0 +1,216 @@ +import fc from 'fast-check' +import { createCipheriv } from 'node:crypto' +import { Brc39StreamFrame } from './Brc39Frame' +import { canonicalPortableChunks } from './CanonicalPortableChunks' +import { decryptBrc39StreamToQuarantine } from './Brc39StreamNode' +import { parseBRC38Json } from './index' +import { + registerArgon2idBackend, + unregisterArgon2idBackend, + type AsyncArgon2idBackend +} from '../../utility/Argon2idBackend' + +const MIN_PROPERTY_RUNS = 300 +fc.configureGlobal({ + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true +}) + +test('independent envelope bytes retain exactly ciphertext/tag under arbitrary bounded fragmentation', () => { + const numRuns = Math.max(300, Number(process.env.FAST_CHECK_NUM_RUNS || 300)) + const seed = Number(process.env.FAST_CHECK_SEED || 3242026) + fc.assert( + fc.property( + fc.uint8Array({ minLength: 1, maxLength: 4096 }), + fc.uint8Array({ minLength: 16, maxLength: 16 }), + fc.integer({ min: 1, max: 255 }), + fc.integer({ min: 1, max: 255 }), + fc.array(fc.integer({ min: 1, max: 128 }), { minLength: 1, maxLength: 15 }), + (ciphertext, tag, saltLength, nonceLength, schedule) => { + const prefix = Buffer.alloc(33 + saltLength + nonceLength, 0) + prefix.write('WDAT') + prefix.set([1, 1, 38, 1, 0, saltLength, nonceLength], 4) + prefix.writeUInt32BE(7, 11) + prefix.writeUInt32BE(131072, 15) + prefix[19] = 1 + prefix[20] = 32 + prefix.fill(42, 33) + const file = Buffer.concat([prefix, ciphertext, tag]) + const frame = new Brc39StreamFrame({ + maximumFileBytes: file.length, + maximumChunkBytes: 128, + maximumIterations: 7, + maximumMemoryKiB: 131072, + maximumParallelism: 1 + }) + const output: Uint8Array[] = [] + let offset = 0, + step = 0 + while (offset < file.length) { + const count = schedule[step++ % schedule.length] + const chunk = file.subarray(offset, offset + count) + const emitted = frame.accept(chunk) + expect(emitted.every(value => value.length <= 128)).toBe(true) + output.push(...emitted) + offset += count + } + const header = frame.header() + expect(header?.salt).toEqual(new Uint8Array(saltLength).fill(42)) + expect(header?.nonce).toEqual(new Uint8Array(nonceLength).fill(42)) + const result = frame.finish() + expect(Buffer.concat(output)).toEqual(Buffer.from(ciphertext)) + expect(result.tag).toEqual(tag) + expect(result.fileBytes).toBe(file.length) + expect(result.ciphertextBytes).toBe(ciphertext.length) + } + ), + { + numRuns, + seed, + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) + +test('generated canonical documents, fragmentation and tag corruption preserve isolated authenticated processing', async () => { + const key = new Uint8Array(32).fill(17) + const backend: AsyncArgon2idBackend = { + preload: async () => {}, + isReady: () => true, + deriveKey: async value => { + expect(value.password).toEqual(new TextEncoder().encode(' Caf\u00e9 ')) + expect(value).toMatchObject({ iterations: 1, memorySize: 64, parallelism: 1, hashLength: 32 }) + return key.slice() + } + } + registerArgon2idBackend(backend) + try { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ maxLength: 1024 }), + fc.integer({ min: 1, max: 100000 }), + fc.uint8Array({ minLength: 32, maxLength: 32 }), + fc.uint8Array({ minLength: 32, maxLength: 32 }), + fc.array(fc.integer({ min: 1, max: 128 }), { minLength: 1, maxLength: 15 }), + fc.boolean(), + async (data, userId, salt, nonce, schedule, corrupt) => { + const iso = '2026-10-03T00:00:00.000Z' + const document = { + brc: 38, + title: 'User Wallet Data Format', + formatVersion: 1, + exportedAt: iso, + sourceStorage: { + created_at: iso, + updated_at: iso, + storageIdentityKey: 'source', + storageName: 'synthetic-' + Buffer.from(data).toString('base64'), + chain: 'test' + }, + user: { + created_at: iso, + updated_at: iso, + userId, + identityKey: 'synthetic-' + userId, + activeStorage: 'source' + }, + tables: { + provenTxs: [], + provenTxReqs: [], + outputBaskets: [], + transactions: [], + commissions: [], + outputs: [], + outputTags: [], + outputTagMaps: [], + txLabels: [], + txLabelMaps: [], + certificates: [], + certificateFields: [], + syncStates: [] + } + } + const plaintext = Buffer.concat([ + ...canonicalPortableChunks(document, { maximumValueBytes: 1048576, maximumChunkBytes: 128 }) + ]) + // Independent standard envelope/native cipher; historical import work + // is encoded exactly, while the selected host backend is controlled. + const prefix = Buffer.alloc(97) + prefix.write('WDAT') + prefix.set([1, 1, 38, 1, 0, 32, 32], 4) + prefix.writeUInt32BE(1, 11) + prefix.writeUInt32BE(64, 15) + prefix[19] = 1 + prefix[20] = 32 + prefix.set(salt, 33) + prefix.set(nonce, 65) + const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 }) + const file = Buffer.concat([prefix, cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]) + if (corrupt) file[file.length - 1] ^= 1 + async function* source() { + let offset = 0, + step = 0 + while (offset < file.length) { + const count = schedule[step++ % schedule.length] + yield new Uint8Array(file.subarray(offset, offset + count)) + offset += count + } + } + const staged: Uint8Array[] = [] + let validated = 0, + discarded = 0 + const quarantine = { + appendUntrusted: async (bytes: Uint8Array) => { + expect(bytes.length).toBeLessThanOrEqual(128) + staged.push(bytes.slice()) + }, + validateAuthenticated: async () => { + const bytes = Buffer.concat(staged) + expect(bytes).toEqual(plaintext) + expect(parseBRC38Json(new TextDecoder('utf-8', { fatal: true }).decode(bytes))).toEqual(document) + validated++ + }, + discard: async () => { + staged.splice(0) + discarded++ + } + } + const pending = decryptBrc39StreamToQuarantine(source(), ' Cafe\u0301 ', quarantine, { + maximumPasswordBytes: 1024, + policy: { + maximumFileBytes: file.length, + maximumChunkBytes: 128, + maximumIterations: 7, + maximumMemoryKiB: 131072, + maximumParallelism: 1 + } + }) + if (corrupt) { + await expect(pending).rejects.toThrow() + expect(validated).toBe(0) + expect(discarded).toBe(1) + expect(staged).toHaveLength(0) + } else { + await expect(pending).resolves.toEqual({ fileBytes: file.length, plaintextBytes: plaintext.length }) + expect(validated).toBe(1) + expect(discarded).toBe(0) + } + } + ), + { + numRuns: Math.max(300, Number(process.env.FAST_CHECK_NUM_RUNS || 300)), + seed: Number(process.env.FAST_CHECK_SEED || 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) + } finally { + unregisterArgon2idBackend(backend) + } +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts new file mode 100644 index 000000000..d05ba8141 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts @@ -0,0 +1,168 @@ +import { createCipheriv, createDecipheriv } from 'node:crypto' +import { AESGCMDecrypt } from '@bsv/sdk/primitives/AESGCM' +import { Brc39StreamFrame, BRC39_STREAM_DEFAULT_KDF, encodeBrc39StreamPrefix } from './Brc39Frame' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' + +const policy = { + maximumFileBytes: 1048576, + maximumChunkBytes: 65536, + maximumIterations: 9, + maximumMemoryKiB: 262144, + maximumParallelism: 4 +} +const salt = Uint8Array.from({ length: 32 }, (_, index) => index) +const nonce = Uint8Array.from({ length: 32 }, (_, index) => 100 + index) +// Independent fixed envelope oracle: this deliberately does not call the encoder. +function file(ciphertext = Buffer.from('synthetic ciphertext'), saltLength = 32, nonceLength = 32): Uint8Array { + const prefix = Buffer.alloc(33 + saltLength + nonceLength) + prefix.write('WDAT') + prefix.set([1, 1, 38, 1, 0, saltLength, nonceLength], 4) + prefix.writeUInt32BE(7, 11) + prefix.writeUInt32BE(131072, 15) + prefix[19] = 1 + prefix[20] = 32 + prefix.fill(42, 33) + return new Uint8Array(Buffer.concat([prefix, ciphertext, Buffer.alloc(16, 99)])) +} +function consume(bytes: Uint8Array, width: number) { + const frame = new Brc39StreamFrame({ ...policy, maximumChunkBytes: width }) + const output: Uint8Array[] = [] + for (let offset = 0; offset < bytes.length; offset += width) + output.push(...frame.accept(bytes.subarray(offset, offset + width))) + const header = frame.header() + const end = frame.finish() + return { header, end, output, ciphertext: Buffer.concat(output) } +} + +test('canonical prefix matches an independent envelope oracle and preserves exact stronger parameters', () => { + const prefix = encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, salt, nonce, policy) + const expected = Buffer.from(file().slice(0, 97)) + expected.set(salt, 33) + expected.set(nonce, 65) + expect(Buffer.from(prefix)).toEqual(expected) + const stronger = encodeBrc39StreamPrefix({ iterations: 9, memoryKiB: 262144, parallelism: 4 }, salt, nonce, policy) + expect(new DataView(stronger.buffer).getUint32(11)).toBe(9) + expect(new DataView(stronger.buffer).getUint32(15)).toBe(262144) + expect(stronger[19]).toBe(4) + salt[0] ^= 1 + expect(prefix[33]).toBe(0) + salt[0] ^= 1 +}) +test.each([1, 2, 15, 16, 17, 32, 33, 34, 65, 97, 128, 65536])( + 'prefix/ciphertext/tag boundaries work at chunk width %i', + width => { + const input = file(Buffer.from('a'.repeat(311))) + const result = consume(input, width) + expect(result.ciphertext).toEqual(Buffer.from('a'.repeat(311))) + expect(result.end.tag).toEqual(new Uint8Array(16).fill(99)) + expect(result.end.fileBytes).toBe(input.length) + expect(result.end.ciphertextBytes).toBe(311) + expect(result.output.every(chunk => chunk.length <= width)).toBe(true) + expect(result.header).toMatchObject({ iterations: 7, memoryKiB: 131072, parallelism: 1 }) + } +) +test.each([ + [1, 1], + [8, 12], + [255, 255] +])('valid non-default imported salt %i and nonce %i lengths remain accepted', (saltLength, nonceLength) => { + const result = consume(file(undefined, saltLength, nonceLength), 13) + expect(result.header?.salt).toHaveLength(saltLength) + expect(result.header?.nonce).toHaveLength(nonceLength) +}) +test.each([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 20, 21, 25, 32])( + 'invalid fixed header byte %i refuses as soon as the fixed header closes', + position => { + const bytes = file() + bytes[position] = position === 9 || position === 10 || position === 19 ? 0 : bytes[position] ^ 1 + const frame = new Brc39StreamFrame(policy) + frame.accept(bytes.slice(0, 32)) + expect(() => frame.accept(bytes.slice(32, 33))).toThrow() + expect(() => frame.accept(new Uint8Array())).toThrow(/closed/) + } +) +test.each([11, 15])('zero encoded work at %i refuses before any derivation or ciphertext', position => { + const bytes = file() + bytes.fill(0, position, position + 4) + expect(() => new Brc39StreamFrame(policy).accept(bytes.slice(0, 33))).toThrow(RangeError) +}) +test.each(['maximumIterations', 'maximumMemoryKiB', 'maximumParallelism'] as const)( + 'explicit %s work policy refuses before salt/nonce allocation', + name => { + const bounded = { ...policy, [name]: 1 } + if (name === 'maximumParallelism') bounded.maximumParallelism = 1 + const bytes = file() + if (name === 'maximumParallelism') bytes[19] = 2 + expect(() => new Brc39StreamFrame(bounded).accept(bytes.slice(0, 33))).toThrow(SnapshotResourceLimitError) + } +) +test('import retains valid weak historical parameters while new exports refuse weaker strength', () => { + const bytes = file() + new DataView(bytes.buffer).setUint32(11, 1) + new DataView(bytes.buffer).setUint32(15, 64) + expect(consume(bytes, 64).header).toMatchObject({ iterations: 1, memoryKiB: 64 }) + expect(() => encodeBrc39StreamPrefix({ ...BRC39_STREAM_DEFAULT_KDF, iterations: 1 }, salt, nonce, policy)).toThrow( + /canonical/ + ) + expect(() => encodeBrc39StreamPrefix({ ...BRC39_STREAM_DEFAULT_KDF, memoryKiB: 64 }, salt, nonce, policy)).toThrow( + /canonical/ + ) +}) +test.each([0, 1, 32, 33, 64, 96, 97, 112])( + 'truncated or empty ciphertext at length %i never produces a completed frame', + length => { + const bytes = file(Buffer.from([1])) + const frame = new Brc39StreamFrame(policy) + frame.accept(bytes.slice(0, length)) + expect(() => frame.finish()).toThrow(/Truncated/) + expect(() => frame.finish()).toThrow(/closed/) + } +) +test('exact file admission and detached policy/header/ciphertext/tag prevent caller mutation', () => { + const bytes = file(Buffer.from('hello')) + const options = { ...policy, maximumFileBytes: bytes.length } + const frame = new Brc39StreamFrame(options) + options.maximumFileBytes = 1 + const chunks = frame.accept(bytes) + const header = frame.header() + expect(header).toBeDefined() + header!.salt.fill(0) + bytes.fill(0) + expect(frame.header()?.salt).toEqual(new Uint8Array(32).fill(42)) + expect(Buffer.concat(chunks)).toEqual(Buffer.from('hello')) + expect(frame.finish().tag).toEqual(new Uint8Array(16).fill(99)) + expect(() => frame.header()).toThrow(/closed/) +}) +test('oversized chunks/files and impossible prefixes refuse with terminal closure', () => { + const bytes = file() + const frame = new Brc39StreamFrame({ ...policy, maximumChunkBytes: 32 }) + expect(() => frame.accept(bytes.slice(0, 33))).toThrow(SnapshotResourceLimitError) + expect(() => frame.finish()).toThrow(/closed/) + const small = new Brc39StreamFrame({ ...policy, maximumFileBytes: bytes.length - 1 }) + expect(() => small.accept(bytes)).toThrow(SnapshotResourceLimitError) + const impossible = new Brc39StreamFrame({ ...policy, maximumFileBytes: 113 }) + expect(() => impossible.accept(bytes.slice(0, 33))).toThrow(SnapshotResourceLimitError) +}) +test.each([0, -1, 1.5, NaN, Infinity])('invalid explicit policy value %s is rejected', value => { + expect(() => new Brc39StreamFrame({ ...policy, maximumFileBytes: value })).toThrow(RangeError) +}) +test('bounded framing agrees with SDK and native AES-GCM for a 32-byte nonce without header AAD', () => { + const key = new Uint8Array(32).fill(31) + const plaintext = Buffer.from('synthetic canonical UTF8 \u00e9 \ud83c\udf3f') + const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 }) + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() + const prefix = encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, salt, nonce, policy) + const result = consume(new Uint8Array(Buffer.concat([prefix, ciphertext, tag])), 7) + expect(Buffer.from(AESGCMDecrypt(result.ciphertext, nonce, result.end.tag, key)!)).toEqual(plaintext) + const decoder = createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 }) + const provisional = decoder.update(result.ciphertext) + decoder.setAuthTag(result.end.tag) + expect(Buffer.concat([provisional, decoder.final()])).toEqual(plaintext) + const unauthenticated = createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 }) + unauthenticated.update(result.ciphertext) + const corrupt = result.end.tag.slice() + corrupt[0] ^= 1 + unauthenticated.setAuthTag(corrupt) + expect(() => unauthenticated.final()).toThrow() +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.ts new file mode 100644 index 000000000..e45e35432 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.ts @@ -0,0 +1,193 @@ +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' + +export interface Brc39StreamPolicy { + maximumFileBytes: number + maximumChunkBytes: number + maximumIterations: number + maximumMemoryKiB: number + maximumParallelism: number +} +export interface Brc39StreamKdf { + iterations: number + memoryKiB: number + parallelism: number +} +export interface Brc39StreamHeader extends Brc39StreamKdf { + salt: Uint8Array + nonce: Uint8Array +} +export const BRC39_STREAM_DEFAULT_KDF: Readonly = Object.freeze({ + iterations: 7, + memoryKiB: 131072, + parallelism: 1 +}) +const fixedHeaderBytes = 33 +const tagBytes = 16 +const maximumPrefixBytes = fixedHeaderBytes + 255 + 255 +const constants = [0x57, 0x44, 0x41, 0x54, 1, 1, 38, 1, 0] + +function positive(value: number, maximum: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) + throw new RangeError(`${name} must be a positive integer no greater than ${maximum}`) +} +function copyPolicy(policy: Brc39StreamPolicy): Readonly { + const result = { ...policy } + positive(result.maximumFileBytes, Number.MAX_SAFE_INTEGER, 'maximumFileBytes') + positive(result.maximumChunkBytes, 65536, 'maximumChunkBytes') + positive(result.maximumIterations, 0xffffffff, 'maximumIterations') + positive(result.maximumMemoryKiB, 0xffffffff, 'maximumMemoryKiB') + positive(result.maximumParallelism, 255, 'maximumParallelism') + return Object.freeze(result) +} +function admitKdf(kdf: Brc39StreamKdf, policy: Readonly): void { + positive(kdf.iterations, 0xffffffff, 'iterations') + positive(kdf.memoryKiB, 0xffffffff, 'memoryKiB') + positive(kdf.parallelism, 255, 'parallelism') + if ( + kdf.iterations > policy.maximumIterations || + kdf.memoryKiB > policy.maximumMemoryKiB || + kdf.parallelism > policy.maximumParallelism + ) + throw new SnapshotResourceLimitError('BRC-39 KDF exceeds the selected stream work policy') +} +function readKdf(prefix: Uint8Array, policy: Readonly): Readonly { + for (let index = 0; index < constants.length; index++) + if (prefix[index] !== constants[index]) throw new TypeError('Unsupported BRC-39 fixed header') + if (prefix[9] === 0 || prefix[10] === 0 || prefix[20] !== 32) + throw new TypeError('Invalid BRC-39 salt, nonce or key length') + for (let index = 21; index < fixedHeaderBytes; index++) + if (prefix[index] !== 0) throw new TypeError('Invalid BRC-39 reserved bytes') + const view = new DataView(prefix.buffer, prefix.byteOffset, fixedHeaderBytes) + const kdf = { iterations: view.getUint32(11), memoryKiB: view.getUint32(15), parallelism: prefix[19] } + admitKdf(kdf, policy) + return Object.freeze(kdf) +} + +/** Bounded prefix encoder. Salt and nonce must come from the host's secure + * random source for each export. This does not validate or encrypt BRC-38. + * Explicit stream work limits never alter the existing materialized API. + */ +export function encodeBrc39StreamPrefix( + options: Brc39StreamKdf, + salt: Uint8Array, + nonce: Uint8Array, + policy: Brc39StreamPolicy +): Uint8Array { + const kdf = { ...options } + const admitted = copyPolicy(policy) + admitKdf(kdf, admitted) + if (kdf.iterations < 7 || kdf.memoryKiB < 131072) + throw new RangeError('BRC-39 new exports require at least the canonical KDF strength') + if (!(salt instanceof Uint8Array) || salt.length !== 32 || !(nonce instanceof Uint8Array) || nonce.length !== 32) + throw new TypeError('BRC-39 new exports require 32-byte salt and nonce') + const result = new Uint8Array(fixedHeaderBytes + salt.length + nonce.length) + if (result.length + tagBytes + 1 > admitted.maximumFileBytes) + throw new SnapshotResourceLimitError('BRC-39 prefix exceeds the selected file policy') + result.set(constants) + result[9] = salt.length + result[10] = nonce.length + const view = new DataView(result.buffer) + view.setUint32(11, kdf.iterations) + view.setUint32(15, kdf.memoryKiB) + result[19] = kdf.parallelism + result[20] = 32 + result.set(salt, fixedHeaderBytes) + result.set(nonce, fixedHeaderBytes + salt.length) + return result +} + +/** Incremental envelope framing only. Emitted ciphertext is unauthenticated; + * decrypt it solely into an isolated quarantine. The caller must verify GCM + * and then valid UTF-8/BRC-38 before import or activation. Header is not AAD. + * Holds at most the 543-byte prefix plus the 16-byte trailing tag; each input + * and detached output is bounded by an explicit policy. No crypto is run. + */ +export class Brc39StreamFrame { + private readonly policy: Readonly + private readonly prefix = new Uint8Array(maximumPrefixBytes) + private prefixUsed = 0 + private prefixRequired = fixedHeaderBytes + private kdf: Readonly | undefined + private readonly tail = new Uint8Array(tagBytes) + private tailUsed = 0 + private fileBytes = 0 + private ciphertextBytes = 0 + private closed = false + + constructor(policy: Brc39StreamPolicy) { + this.policy = copyPolicy(policy) + } + private live(): void { + if (this.closed) throw new Error('BRC-39 stream frame is closed') + } + private collectPrefix(input: Uint8Array): number { + let offset = 0 + while (this.prefixUsed < this.prefixRequired && offset < input.length) { + const count = Math.min(this.prefixRequired - this.prefixUsed, input.length - offset) + this.prefix.set(input.subarray(offset, offset + count), this.prefixUsed) + this.prefixUsed += count + offset += count + if (this.prefixUsed === fixedHeaderBytes && this.kdf === undefined) { + this.kdf = readKdf(this.prefix, this.policy) + this.prefixRequired = fixedHeaderBytes + this.prefix[9] + this.prefix[10] + if (this.prefixRequired + tagBytes + 1 > this.policy.maximumFileBytes) + throw new SnapshotResourceLimitError('BRC-39 prefix exceeds the selected file policy') + } + } + return offset + } + private ciphertext(input: Uint8Array): Uint8Array[] { + const emit = Math.max(0, this.tailUsed + input.length - tagBytes) + const fromTail = Math.min(emit, this.tailUsed) + const fromInput = emit - fromTail + const result: Uint8Array[] = [] + if (fromTail !== 0) result.push(this.tail.slice(0, fromTail)) + if (fromInput !== 0) result.push(input.slice(0, fromInput)) + this.tail.copyWithin(0, fromTail, this.tailUsed) + this.tailUsed -= fromTail + this.tail.set(input.subarray(fromInput), this.tailUsed) + this.tailUsed += input.length - fromInput + this.ciphertextBytes += emit + return result + } + accept(chunk: Uint8Array): Uint8Array[] { + this.live() + try { + if (!(chunk instanceof Uint8Array)) throw new TypeError('BRC-39 stream requires byte chunks') + if (chunk.length > this.policy.maximumChunkBytes) + throw new SnapshotResourceLimitError('BRC-39 input chunk exceeds the selected stream policy') + if (chunk.length > this.policy.maximumFileBytes - this.fileBytes) + throw new SnapshotResourceLimitError('BRC-39 file exceeds the selected stream policy') + this.fileBytes += chunk.length + const offset = this.collectPrefix(chunk) + if (this.prefixUsed < this.prefixRequired) return [] + return this.ciphertext(chunk.subarray(offset)) + } catch (error) { + this.closed = true + throw error + } + } + /** Every returned byte array is detached from both caller and frame state. */ + header(): Brc39StreamHeader | undefined { + this.live() + if (this.kdf === undefined || this.prefixUsed < this.prefixRequired) return undefined + const saltEnd = fixedHeaderBytes + this.prefix[9] + return { + ...this.kdf, + salt: this.prefix.slice(fixedHeaderBytes, saltEnd), + nonce: this.prefix.slice(saltEnd, this.prefixRequired) + } + } + finish(): Readonly<{ tag: Uint8Array; fileBytes: number; ciphertextBytes: number }> { + this.live() + this.closed = true + if ( + this.kdf === undefined || + this.prefixUsed < this.prefixRequired || + this.tailUsed !== tagBytes || + this.ciphertextBytes === 0 + ) + throw new TypeError('Truncated or empty BRC-39 ciphertext') + return Object.freeze({ tag: this.tail.slice(), fileBytes: this.fileBytes, ciphertextBytes: this.ciphertextBytes }) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts new file mode 100644 index 000000000..c4d8dc3fa --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts @@ -0,0 +1,525 @@ +import { mkdtemp, readdir, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { FileHandle } from 'node:fs/promises' +import fc from 'fast-check' +import { createBrc39NodeFileQuarantine, type Brc39NodeFileQuarantine } from './Brc39PrivateFileNode' +import { decryptBrc39StreamToQuarantine } from './Brc39StreamNode' +import { encryptBRC39, parseBRC38Json, type BRC38WalletData } from './index' +import { + registerArgon2idBackend, + unregisterArgon2idBackend, + type AsyncArgon2idBackend +} from '../../utility/Argon2idBackend' + +const MIN_PROPERTY_RUNS = 300 +fc.configureGlobal({ + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true +}) + +const policy = { maximumFileBytes: 1048576, maximumChunkBytes: 64 } +let parent: string +let backend: AsyncArgon2idBackend | undefined +beforeEach(async () => { + parent = await mkdtemp(join(tmpdir(), 'ts-stack-file-quarantine-')) +}) +afterEach(async () => { + if (backend !== undefined) unregisterArgon2idBackend(backend) + backend = undefined + jest.restoreAllMocks() + await rm(parent, { recursive: true, force: true }) +}) +async function collect(chunks: AsyncIterable): Promise { + const parts: Uint8Array[] = [] + for await (const bytes of chunks) { + expect(bytes.length).toBeLessThanOrEqual(64) + parts.push(bytes) + } + // These bounded synthetic fixtures are materialized only by the test oracle. + return Buffer.concat(parts) +} +async function stagedPath(): Promise<{ directory: string; filename: string }> { + const names = await readdir(parent) + expect(names).toHaveLength(1) + const directory = join(parent, names[0]) + return { directory, filename: join(directory, 'quarantine') } +} +async function* pieces(bytes: Uint8Array, width = 31) { + for (let offset = 0; offset < bytes.length; offset += width) yield bytes.slice(offset, offset + width) +} +async function append(stage: Brc39NodeFileQuarantine, bytes: Uint8Array, width = 31): Promise { + async function* writes() { + for await (const chunk of pieces(bytes, width)) yield stage.appendUntrusted(chunk) + } + for await (const result of writes()) expect(result).toBeUndefined() +} + +function observeHandles(): FileHandle[] { + const fs = jest.requireActual('node:fs/promises') + const actual = fs.open + const handles: FileHandle[] = [] + jest.spyOn(fs, 'open').mockImplementation(async (...args) => { + const handle = await actual(...args) + handles.push(handle) + return handle + }) + return handles +} + +test('short writes retain every byte in order with actual file backing', async () => { + const handles = observeHandles() + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(Buffer.from([1, 2, 3])) + }, + policy + ) + // Simulate the first write having committed exactly one byte. Its fulfilled + // result is returned once; the remaining write uses the real file handle. + await handles[0].write(new Uint8Array([1]), 0, 1, null) + const writes = jest.spyOn(handles[0], 'write').mockResolvedValueOnce({ bytesWritten: 1, buffer: '' }) + await stage.appendUntrusted(new Uint8Array([1, 2, 3])) + expect(writes).toHaveBeenCalledTimes(2) + expect(writes.mock.calls[1].slice(1)).toEqual([1, 2, null]) + await stage.validateAuthenticated() + await stage.discard() +}) + +test.each([0, -1, 4, NaN])('invalid write progress %s refuses and permits cleanup', async bytesWritten => { + const handles = observeHandles() + const stage = await createBrc39NodeFileQuarantine(parent, async () => {}, policy) + jest.spyOn(handles[0], 'write').mockResolvedValueOnce({ bytesWritten, buffer: '' }) + await expect(stage.appendUntrusted(new Uint8Array([1, 2, 3]))).rejects.toThrow('invalid progress') + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + +test('filesystem write failure retains exact cause and refuses further work', async () => { + const handles = observeHandles() + const reason = Object.assign(new Error('synthetic disk pressure'), { code: 'ENOSPC' }) + const stage = await createBrc39NodeFileQuarantine(parent, async () => {}, policy) + jest.spyOn(handles[0], 'write').mockRejectedValueOnce(reason) + await expect(stage.appendUntrusted(new Uint8Array([1]))).rejects.toBe(reason) + await expect(stage.appendUntrusted(new Uint8Array([1]))).rejects.toThrow('not writable') + await stage.discard() +}) + +test('fsync failure refuses validation with exact cause', async () => { + const handles = observeHandles() + const reason = new Error('synthetic fsync failure') + const validator = jest.fn(async (chunks: AsyncIterable) => { + await collect(chunks) + }) + const stage = await createBrc39NodeFileQuarantine(parent, validator, policy) + await stage.appendUntrusted(new Uint8Array([1])) + jest.spyOn(handles[0], 'sync').mockRejectedValueOnce(reason) + await expect(stage.validateAuthenticated()).rejects.toBe(reason) + expect(validator).not.toHaveBeenCalled() + await stage.discard() +}) + +test('writer close failure retains its owned handle for cleanup retry', async () => { + const handles = observeHandles() + const reason = new Error('synthetic writer close failure') + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + await collect(chunks) + }, + policy + ) + await stage.appendUntrusted(new Uint8Array([1])) + const close = jest.spyOn(handles[0], 'close').mockRejectedValueOnce(reason) + await expect(stage.validateAuthenticated()).rejects.toBe(reason) + await stage.discard() + expect(close).toHaveBeenCalledTimes(2) + expect(await readdir(parent)).toEqual([]) +}) + +test('semantic and reader cleanup failures are retained together and cleanup retries the reader', async () => { + const handles = observeHandles() + const original = new Error('synthetic invalid BRC-38') + const cleanup = new Error('synthetic reader close failure') + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + await collect(chunks) + jest.spyOn(handles[1], 'close').mockRejectedValueOnce(cleanup) + throw original + }, + policy + ) + await stage.appendUntrusted(new Uint8Array([1])) + let failure: unknown + try { + await stage.validateAuthenticated() + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + expect(failure).toMatchObject({ cause: original, errors: [original, cleanup] }) + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + +test('failed filesystem removal is explicit and a subsequent discard can retry', async () => { + const fs = jest.requireActual('node:fs/promises') + const reason = new Error('synthetic removal failure') + const stage = await createBrc39NodeFileQuarantine(parent, async () => {}, policy) + jest.spyOn(fs, 'rm').mockRejectedValueOnce(reason) + await expect(stage.discard()).rejects.toMatchObject({ errors: [reason] }) + expect(await readdir(parent)).toHaveLength(1) + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + +test('factory file creation and removal failures both remain visible', async () => { + const fs = jest.requireActual('node:fs/promises') + const original = new Error('synthetic file creation failure') + const cleanup = new Error('synthetic factory removal failure') + jest.spyOn(fs, 'open').mockRejectedValueOnce(original) + jest.spyOn(fs, 'rm').mockRejectedValueOnce(cleanup) + await expect(createBrc39NodeFileQuarantine(parent, async () => {}, policy)).rejects.toMatchObject({ + cause: original, + errors: [original, cleanup] + }) + expect(await readdir(parent)).toHaveLength(1) +}) + +test('private file modes and detached writes precede authenticated bounded readback', async () => { + let validated = 0 + const expected = Buffer.from('private UTF8 data: e\u0301 \u{1f642}') + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(expected) + validated++ + }, + policy + ) + expect(Object.isFrozen(stage)).toBe(true) + expect(Object.keys(stage)).toEqual(['appendUntrusted', 'validateAuthenticated', 'withAuthenticatedChunks', 'discard']) + await expect(stage.withAuthenticatedChunks(collect)).rejects.toThrow('has not been validated') + const { directory, filename } = await stagedPath() + expect((await stat(directory)).mode & 0o777).toBe(0o700) + expect((await stat(filename)).mode & 0o777).toBe(0o600) + const input = new Uint8Array(expected) + const pending = stage.appendUntrusted(input) + input.fill(0) + await pending + expect(await readFile(filename)).toEqual(expected) + expect(validated).toBe(0) + await stage.validateAuthenticated() + expect(validated).toBe(1) + expect(await stage.withAuthenticatedChunks(collect)).toEqual(expected) + await expect(stage.appendUntrusted(new Uint8Array([1]))).rejects.toThrow('not writable') + await expect(stage.validateAuthenticated()).rejects.toThrow('cannot be validated again') + await stage.discard() + await stage.discard() + expect(await readdir(parent)).toEqual([]) + await expect(stage.withAuthenticatedChunks(collect)).rejects.toThrow('has not been validated') +}) + +test('concurrent append refuses instead of retaining queued buffers', async () => { + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(Buffer.from([1, 2])) + }, + policy + ) + const pending = stage.appendUntrusted(new Uint8Array([1, 2])) + await expect(stage.appendUntrusted(new Uint8Array([3, 4]))).rejects.toThrow('already owns an operation') + await pending + await stage.validateAuthenticated() + await stage.discard() +}) + +test('chunk and cumulative file bounds refuse before additional bytes reach disk', async () => { + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(Buffer.from([1, 2, 3])) + }, + { maximumFileBytes: 3, maximumChunkBytes: 2 } + ) + await expect(stage.appendUntrusted(new Uint8Array([1, 2, 3]))).rejects.toThrow('byte policy') + await stage.appendUntrusted(new Uint8Array([1, 2])) + await expect(stage.appendUntrusted(new Uint8Array([3, 4]))).rejects.toThrow('byte policy') + await stage.appendUntrusted(new Uint8Array([3])) + await stage.validateAuthenticated() + await stage.discard() +}) + +test.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + 'invalid file bound %s creates no private file', + async maximumFileBytes => { + await expect( + createBrc39NodeFileQuarantine(parent, async () => {}, { + ...policy, + maximumFileBytes + }) + ).rejects.toThrow('positive safe integer') + expect(await readdir(parent)).toEqual([]) + } +) +test.each([0, -1, 1.5, NaN, Infinity, 65537])( + 'invalid chunk bound %s creates no private file', + async maximumChunkBytes => { + await expect( + createBrc39NodeFileQuarantine(parent, async () => {}, { + ...policy, + maximumChunkBytes + }) + ).rejects.toThrow('integer from 1 to 65536') + expect(await readdir(parent)).toEqual([]) + } +) + +test('validator early completion refuses authentication and its reader is released', async () => { + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + for await (const chunk of chunks) { + expect(chunk).toHaveLength(64) + break + } + }, + policy + ) + await append(stage, new Uint8Array(200).fill(17)) + await expect(stage.validateAuthenticated()).rejects.toThrow('did not consume the complete file') + await expect(stage.withAuthenticatedChunks(collect)).rejects.toThrow('has not been validated') + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + +test('semantic failure retains its exact cause, closes the reader and permits cleanup', async () => { + const reason = new Error('independent semantic validation failed') + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + await collect(chunks) + throw reason + }, + policy + ) + await append(stage, Buffer.from('bounded invalid document')) + await expect(stage.validateAuthenticated()).rejects.toBe(reason) + await expect(stage.appendUntrusted(new Uint8Array([1]))).rejects.toThrow('not writable') + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + +test('authenticated early read completion closes its iterator and later reads remain independent', async () => { + const bytes = new Uint8Array(151).map((_, index) => index) + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(Buffer.from(bytes)) + }, + policy + ) + await append(stage, bytes) + await stage.validateAuthenticated() + const first = await stage.withAuthenticatedChunks(async chunks => { + for await (const chunk of chunks) return chunk + throw new Error('missing first chunk') + }) + expect(first).toEqual(bytes.slice(0, 64)) + expect(await stage.withAuthenticatedChunks(collect)).toEqual(Buffer.from(bytes)) + await stage.discard() +}) + +test('external truncation is refused without validating an incomplete file', async () => { + const validator = jest.fn(async (chunks: AsyncIterable) => { + await collect(chunks) + }) + const stage = await createBrc39NodeFileQuarantine(parent, validator, policy) + await append(stage, Buffer.from('complete source')) + await truncate((await stagedPath()).filename, 2) + await expect(stage.validateAuthenticated()).rejects.toThrow('size changed') + expect(validator).not.toHaveBeenCalled() + await stage.discard() +}) + +test('same-size external changes refuse readback despite valid file length', async () => { + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + await collect(chunks) + }, + policy + ) + await append(stage, Buffer.from('original')) + await writeFile((await stagedPath()).filename, Buffer.from('modified')) + await expect(stage.validateAuthenticated()).rejects.toThrow('bytes changed') + await expect(stage.withAuthenticatedChunks(collect)).rejects.toThrow('has not been validated') + await stage.discard() +}) + +test('cancellation prevents file creation or authenticated access with the exact reason', async () => { + const controller = new AbortController() + const reason = new Error('operator cancellation') + controller.abort(reason) + await expect( + createBrc39NodeFileQuarantine(parent, async () => {}, { + ...policy, + signal: controller.signal + }) + ).rejects.toBe(reason) + expect(await readdir(parent)).toEqual([]) + const active = new AbortController() + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + await collect(chunks) + }, + { + ...policy, + signal: active.signal + } + ) + await append(stage, Buffer.from('private')) + active.abort(reason) + await expect(stage.validateAuthenticated()).rejects.toBe(reason) + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + +test('discard waits for an owned validator before deleting its file', async () => { + let complete!: () => void + let entered!: () => void + const started = new Promise(resolve => { + entered = resolve + }) + const waiting = new Promise(resolve => { + complete = resolve + }) + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + await collect(chunks) + entered() + await waiting + }, + policy + ) + await append(stage, Buffer.from('private source')) + const validation = stage.validateAuthenticated() + const rejected = expect(validation).rejects.toThrow('discarded during validation') + await started + let discarded = false + const closing = stage.discard().then(() => { + discarded = true + }) + await Promise.resolve() + expect(discarded).toBe(false) + expect(await readdir(parent)).toHaveLength(1) + complete() + await rejected + await closing + expect(await readdir(parent)).toEqual([]) +}) + +function document(): BRC38WalletData { + const iso = '2026-10-03T00:00:00.000Z' + return { + brc: 38, + title: 'User Wallet Data Format', + formatVersion: 1, + exportedAt: iso, + sourceStorage: { + created_at: iso, + updated_at: iso, + storageIdentityKey: 'source', + storageName: 'source', + chain: 'test' + }, + user: { created_at: iso, updated_at: iso, userId: 1, identityKey: 'identity', activeStorage: 'source' }, + tables: { + provenTxs: [], + provenTxReqs: [], + outputBaskets: [], + transactions: [], + commissions: [], + outputs: [], + outputTags: [], + outputTagMaps: [], + txLabels: [], + txLabelMaps: [], + certificates: [], + certificateFields: [], + syncStates: [] + } + } +} +test.each([false, true])( + 'legacy envelope file quarantine exposes semantics only after authentication; damaged=%s', + async damaged => { + const key = new Uint8Array(32).fill(17) + backend = { preload: async () => {}, isReady: () => true, deriveKey: async () => key.slice() } + registerArgon2idBackend(backend) + const expected = document() + const encrypted = await encryptBRC39(expected, 'e\u0301 password') + if (damaged) encrypted[encrypted.length - 1] ^= 1 + const validator = jest.fn(async (chunks: AsyncIterable) => { + const bytes = await collect(chunks) + expect(parseBRC38Json(new TextDecoder('utf-8', { fatal: true }).decode(bytes))).toEqual(expected) + }) + const stage = await createBrc39NodeFileQuarantine(parent, validator, policy) + const result = decryptBrc39StreamToQuarantine(pieces(new Uint8Array(encrypted)), '\u00e9 password', stage, { + policy: { ...policy, maximumIterations: 7, maximumMemoryKiB: 131072, maximumParallelism: 1 }, + maximumPasswordBytes: 1024 + }) + if (damaged) { + await expect(result).rejects.toThrow() + expect(validator).not.toHaveBeenCalled() + expect(await readdir(parent)).toEqual([]) + } else { + expect((await result).plaintextBytes).toBeGreaterThan(0) + expect(validator).toHaveBeenCalledTimes(1) + expect(parseBRC38Json((await stage.withAuthenticatedChunks(collect)).toString())).toEqual(expected) + await stage.discard() + } + } +) + +test('generated bounded file chunks retain exact independent bytes and cleanup', async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 1, maxLength: 512 }), + fc.integer({ min: 1, max: 64 }), + async (bytes, width) => { + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(Buffer.from(bytes)) + }, + policy + ) + try { + await append(stage, bytes, width) + await stage.validateAuthenticated() + expect(await stage.withAuthenticatedChunks(collect)).toEqual(Buffer.from(bytes)) + } finally { + await stage.discard() + } + expect(await readdir(parent)).toEqual([]) + } + ), + { + numRuns: Math.max(300, Number(process.env.FAST_CHECK_NUM_RUNS ?? 300)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + path: process.env.FAST_CHECK_PATH ?? '', + endOnFailure: true, + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts new file mode 100644 index 000000000..9f6201111 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts @@ -0,0 +1,280 @@ +import { chmod, mkdtemp, open, rm, type FileHandle } from 'node:fs/promises' +import { join } from 'node:path' +import { createHash, timingSafeEqual } from 'node:crypto' +import { runInSeries } from '../../utility/runInSeries' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' +import type { Brc39StreamQuarantine } from './Brc39StreamNode' + +type Consume = (chunks: AsyncIterable) => Promise +export interface Brc39NodeFilePolicy { + maximumFileBytes: number + maximumChunkBytes: number + signal?: AbortSignal +} +export interface Brc39NodeFileQuarantine extends Brc39StreamQuarantine { + /** Only available after the authenticated semantic validator completes. + * The callback owns one bounded reader; its settlement closes that reader, + * including early completion. This method does not import or activate data. */ + withAuthenticatedChunks: (consume: Consume) => Promise +} +type State = 'writing' | 'validating' | 'validated' | 'failed' | 'discarding' | 'discarded' + +function combined(original: unknown, cleanup: unknown): AggregateError { + return new AggregateError([original, cleanup], 'Private quarantine operation and cleanup failed', { + cause: original + }) +} + +class PrivateFile { + private state: State = 'writing' + private bytes = 0 + private busy = false + private active: Promise = Promise.resolve() + private discarded?: Promise + private writer?: FileHandle + private reader?: FileHandle + private readonly digest = createHash('sha256') + + constructor( + private readonly directory: string, + private readonly filename: string, + writer: FileHandle, + private readonly policy: Readonly, + private readonly validate: Consume + ) { + this.writer = writer + } + + private check(): void { + this.policy.signal?.throwIfAborted() + } + + private checkNotDiscarded(): void { + if (this.state === 'discarding') throw new Error('Private quarantine was discarded during validation') + } + + private own(action: () => Promise): Promise { + if (this.busy) return Promise.reject(new Error('Private quarantine already owns an operation')) + if (this.state === 'failed' || this.state === 'discarding' || this.state === 'discarded') + return Promise.reject(new Error('Private quarantine is closed')) + this.busy = true + const result = action() + .catch(error => { + if (this.state !== 'discarding') this.state = 'failed' + throw error + }) + .finally(() => { + this.busy = false + }) + // Cleanup waits for settlement without changing the caller's exact result. + this.active = result.then( + () => {}, + () => {} + ) + return result + } + + appendUntrusted(input: Uint8Array): Promise { + if (this.state !== 'writing') return Promise.reject(new Error('Private quarantine is not writable')) + if (!(input instanceof Uint8Array)) return Promise.reject(new TypeError('Private quarantine requires bytes')) + if (input.length > this.policy.maximumChunkBytes || input.length > this.policy.maximumFileBytes - this.bytes) + return Promise.reject(new SnapshotResourceLimitError('Private quarantine exceeds the selected byte policy')) + if (this.busy) return Promise.reject(new Error('Private quarantine already owns an operation')) + // Detach before asynchronous ownership. Concurrent calls refuse rather than + // retaining an unbounded queue of detached buffers. + const bytes = input.slice() + return this.own(async () => { + this.check() + const writer = this.writer + if (writer === undefined) throw new Error('Private quarantine writer is closed') + const write = (bytes: Uint8Array, offset: number) => writer.write(bytes, offset, bytes.length - offset, null) + let offset = 0 + const check = () => this.check() + async function* writes() { + while (offset < bytes.length) { + check() + yield write(bytes, offset) + } + } + for await (const { bytesWritten } of writes()) { + if (!Number.isSafeInteger(bytesWritten) || bytesWritten < 1 || bytesWritten > bytes.length - offset) + throw new Error('Private quarantine write made invalid progress') + this.digest.update(bytes.subarray(offset, offset + bytesWritten)) + offset += bytesWritten + this.bytes += bytesWritten + this.check() + } + }) + } + + private async consume(callback: Consume, requireComplete: boolean): Promise { + this.check() + const reader = await open(this.filename, 'r') + this.reader = reader + let offset = 0 + let complete = false + const size = this.bytes + const expectedDigest = this.digest.copy().digest() + const actualDigest = createHash('sha256') + const maximum = this.policy.maximumChunkBytes + const check = () => this.check() + async function* reads() { + while (offset < size) { + check() + const bytes = new Uint8Array(Math.min(maximum, size - offset)) + yield reader.read(bytes, 0, bytes.length, offset) + } + } + async function* chunks() { + for await (const { buffer, bytesRead } of reads()) { + if (!Number.isSafeInteger(bytesRead) || bytesRead < 1 || bytesRead > buffer.length) + throw new Error('Private quarantine read made invalid progress') + offset += bytesRead + check() + actualDigest.update(buffer.subarray(0, bytesRead)) + yield bytesRead === buffer.length ? buffer : buffer.slice(0, bytesRead) + } + if ((await reader.stat()).size !== size) throw new Error('Private quarantine size changed') + if (!timingSafeEqual(actualDigest.digest(), expectedDigest)) throw new Error('Private quarantine bytes changed') + complete = true + } + const iterator = chunks() + let failure: { error: unknown } | undefined + let result: T | undefined + try { + if ((await reader.stat()).size !== size) throw new Error('Private quarantine size changed') + result = await callback(iterator) + this.check() + if (requireComplete && !complete) + throw new Error('Private quarantine validator did not consume the complete file') + } catch (error) { + failure = { error } + } + await runInSeries( + [ + async () => { + await iterator.return(undefined) + }, + async () => { + await reader.close() + this.reader = undefined + } + ], + async close => { + try { + await close() + } catch (cleanup) { + failure = { error: failure === undefined ? cleanup : combined(failure.error, cleanup) } + } + } + ) + if (failure !== undefined) throw failure.error + return result as T + } + + validateAuthenticated(): Promise { + if (this.state !== 'writing') return Promise.reject(new Error('Private quarantine cannot be validated again')) + return this.own(async () => { + this.check() + const writer = this.writer + if (writer === undefined) throw new Error('Private quarantine writer is closed') + if (this.bytes === 0) throw new TypeError('Private quarantine is empty') + if ((await writer.stat()).size !== this.bytes) throw new Error('Private quarantine size changed') + await writer.sync() + this.check() + await writer.close() + this.writer = undefined + this.checkNotDiscarded() + this.state = 'validating' + await this.consume(this.validate, true) + this.checkNotDiscarded() + this.state = 'validated' + }) + } + + withAuthenticatedChunks(consume: Consume): Promise { + if (this.state !== 'validated') return Promise.reject(new Error('Private quarantine has not been validated')) + return this.own(async () => this.consume(consume, false)) + } + + discard(): Promise { + if (this.discarded !== undefined) return this.discarded + this.state = 'discarding' + const release = async (): Promise => { + const errors: unknown[] = [] + await runInSeries(['writer', 'reader'] as const, async kind => { + const handle = this[kind] + if (handle === undefined) return + try { + await handle.close() + this[kind] = undefined + } catch (error) { + errors.push(error) + } + }) + try { + await rm(this.directory, { recursive: true, force: true }) + } catch (error) { + errors.push(error) + } + if (errors.length > 0) throw new AggregateError(errors, 'Private quarantine cleanup failed') + this.state = 'discarded' + } + this.discarded = this.active.then(release).catch(error => { + this.discarded = undefined + this.state = 'failed' + throw error + }) + return this.discarded + } +} + +/** Node-only private file quarantine. The parent is a trusted, + * caller-owned directory, not an untrusted path or shared upload root. Only + * the crypto pipeline may invoke validateAuthenticated after GCM completion; + * the supplied validator must independently validate strict UTF8 and complete + * BRC-38 semantics with bounded reads/storage. No path or provisional reader + * is exposed. Private 0700/0600 staging, fsync and cleanup do not constitute a + * durable import, crash-recovery transaction or activation authorization. */ +export async function createBrc39NodeFileQuarantine( + parent: string, + validate: Consume, + options: Brc39NodeFilePolicy +): Promise { + if (!Number.isSafeInteger(options.maximumFileBytes) || options.maximumFileBytes < 1) + throw new RangeError('maximumFileBytes must be a positive safe integer') + if ( + !Number.isSafeInteger(options.maximumChunkBytes) || + options.maximumChunkBytes < 1 || + options.maximumChunkBytes > 65536 + ) + throw new RangeError('maximumChunkBytes must be an integer from 1 to 65536') + const policy = Object.freeze({ ...options }) + policy.signal?.throwIfAborted() + const directory = await mkdtemp(join(parent, 'brc39-stage-')) + let stage: PrivateFile | undefined + try { + await chmod(directory, 0o700) + policy.signal?.throwIfAborted() + const filename = join(directory, 'quarantine') + const writer = await open(filename, 'wx+', 0o600) + const owned = new PrivateFile(directory, filename, writer, policy, validate) + stage = owned + policy.signal?.throwIfAborted() + const result: Brc39NodeFileQuarantine = { + appendUntrusted: bytes => owned.appendUntrusted(bytes), + validateAuthenticated: () => owned.validateAuthenticated(), + withAuthenticatedChunks: consume => owned.withAuthenticatedChunks(consume), + discard: () => owned.discard() + } + return Object.freeze(result) + } catch (error) { + try { + if (stage === undefined) await rm(directory, { recursive: true, force: true }) + else await stage.discard() + } catch (cleanup) { + throw combined(error, cleanup) + } + throw error + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts new file mode 100644 index 000000000..e7a004eb6 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts @@ -0,0 +1,584 @@ +import { createCipheriv } from 'node:crypto' +import { + decryptBrc39StreamToQuarantine, + encryptBrc39StreamToQuarantine, + type Brc39StreamQuarantine, + type Brc39StreamPlaintext +} from './Brc39StreamNode' +import { encodeBrc39StreamPrefix, BRC39_STREAM_DEFAULT_KDF, Brc39StreamFrame } from './Brc39Frame' +import { encryptBRC39, decryptBRC39, parseBRC38Json, type BRC38WalletData } from './index' +import { canonicalPortableChunks } from './CanonicalPortableChunks' +import fc from 'fast-check' +import { AESGCM } from '@bsv/sdk/primitives/AESGCM' +import { + registerArgon2idBackend, + unregisterArgon2idBackend, + type AsyncArgon2idBackend, + type Argon2idOptions +} from '../../utility/Argon2idBackend' + +const MIN_PROPERTY_RUNS = 300 +fc.configureGlobal({ + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true +}) + +const policy = { + maximumFileBytes: 1048576, + maximumChunkBytes: 128, + maximumIterations: 7, + maximumMemoryKiB: 131072, + maximumParallelism: 1 +} +const options = { policy, maximumPasswordBytes: 1024 } +const key = new Uint8Array(32).fill(17) +const nonce = new Uint8Array(32).fill(19) +const salt = new Uint8Array(32).fill(23) +function document(): BRC38WalletData { + const iso = '2026-10-03T00:00:00.000Z' + return { + brc: 38, + title: 'User Wallet Data Format', + formatVersion: 1, + exportedAt: iso, + sourceStorage: { + created_at: iso, + updated_at: iso, + storageIdentityKey: 'source', + storageName: 'source', + chain: 'test' + }, + user: { created_at: iso, updated_at: iso, userId: 1, identityKey: 'identity', activeStorage: 'source' }, + tables: { + provenTxs: [], + provenTxReqs: [], + outputBaskets: [], + transactions: [], + commissions: [], + outputs: [], + outputTags: [], + outputTagMaps: [], + txLabels: [], + txLabelMaps: [], + certificates: [], + certificateFields: [], + syncStates: [] + } + } +} +function encrypted(plaintext = Buffer.from(JSON.stringify(document()))): Uint8Array { + const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 }) + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const prefix = encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, salt, nonce, policy) + return new Uint8Array(Buffer.concat([prefix, ciphertext, cipher.getAuthTag()])) +} +async function* chunks(bytes: Uint8Array, width = 31) { + for (let offset = 0; offset < bytes.length; offset += width) yield bytes.slice(offset, offset + width) +} +function stage(): Brc39StreamQuarantine & { parts: Uint8Array[]; validated: number; discarded: number } { + return { + parts: [], + validated: 0, + discarded: 0, + async appendUntrusted(bytes) { + this.parts.push(bytes.slice()) + }, + async validateAuthenticated() { + const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(this.parts)) + parseBRC38Json(text) + this.validated++ + }, + async discard() { + this.parts = [] + this.discarded++ + } + } +} +let backend: AsyncArgon2idBackend | undefined +beforeEach(() => { + backend = { preload: async () => {}, isReady: () => true, deriveKey: async () => key.slice() } + registerArgon2idBackend(backend) +}) +afterEach(() => { + if (backend !== undefined) unregisterArgon2idBackend(backend) + jest.restoreAllMocks() +}) + +function plaintextSource(value = document(), width = 31): Brc39StreamPlaintext { + const bytes = Buffer.concat([...canonicalPortableChunks(value, { maximumValueBytes: 1048576 })]) + return { + chunks: chunks(bytes, width), + validateCompleted: async () => { + expect(parseBRC38Json(bytes.toString())).toEqual(value) + } + } +} + +test('owned source closes after semantic validation and before the authentication tag is written', async () => { + const source = plaintextSource() + const output = stage() + const length = Buffer.concat([...canonicalPortableChunks(document(), { maximumValueBytes: 1048576 })]).length + const validate = source.validateCompleted + let validated = false + source.validateCompleted = async () => { + await validate() + validated = true + } + source.close = jest.fn(async () => { + expect(validated).toBe(true) + expect(Buffer.concat(output.parts)).toHaveLength(97 + length) + }) + const receipt = await encryptBrc39StreamToQuarantine(source, 'password', output, options) + expect(receipt.fileBytes).toBe(97 + length + 16) + expect(source.close).toHaveBeenCalledTimes(1) +}) + +test('owned source close failure refuses the tag and retains exact cause with idempotent cleanup', async () => { + const source = plaintextSource() + const output = stage() + const reason = new Error('synthetic source release failure') + source.close = jest.fn(async () => { + throw reason + }) + await expect(encryptBrc39StreamToQuarantine(source, 'password', output, options)).rejects.toBe(reason) + expect(source.close).toHaveBeenCalledTimes(2) + expect(output.discarded).toBe(1) + expect(output.parts).toEqual([]) +}) + +test('output and owned source cleanup failures remain visible together', async () => { + const source = plaintextSource() + const output = stage() + const original = new Error('synthetic private output failure') + const cleanup = new Error('synthetic source cleanup failure') + output.appendUntrusted = async () => { + throw original + } + source.close = jest.fn(async () => { + throw cleanup + }) + await expect(encryptBrc39StreamToQuarantine(source, 'password', output, options)).rejects.toMatchObject({ + cause: original, + errors: [original, cleanup] + }) + expect(source.close).toHaveBeenCalledTimes(1) + expect(output.discarded).toBe(1) +}) +test.each([1, 15, 16, 17, 31, 33, 65, 97, 128])( + 'bounded encryption width %i produces a standard file accepted by the independent SDK codec', + async width => { + const output = stage() + const source = plaintextSource(document(), width) + let validated = 0 + const check = source.validateCompleted + source.validateCompleted = async () => { + await check() + const provisional = Buffer.concat(output.parts) + expect(provisional).toHaveLength(97 + Buffer.byteLength(JSON.stringify(document()))) + validated++ + } + const progress: Array> = [] + const result = await encryptBrc39StreamToQuarantine(source, 'Cafe\u0301', output, { + ...options, + policy: { ...policy, maximumChunkBytes: width }, + onProgress: value => progress.push(value) + }) + const file = new Uint8Array(Buffer.concat(output.parts)) + expect(await decryptBRC39(file, 'Caf\u00e9')).toEqual(document()) + expect(validated).toBe(1) + expect(output.discarded).toBe(0) + expect(output.validated).toBe(0) + expect(output.parts.every(part => part.length <= width)).toBe(true) + expect(result).toEqual({ fileBytes: file.length, plaintextBytes: file.length - 113 }) + expect(Object.isFrozen(result)).toBe(true) + expect(progress.at(-1)).toEqual(result) + expect(progress.every(Object.isFrozen)).toBe(true) + } +) +test('bounded encryption uses real canonical Argon2id strength and NFC with legacy decryption', async () => { + unregisterArgon2idBackend(backend!) + const output = stage() + await encryptBrc39StreamToQuarantine(plaintextSource(), 'Cafe\u0301', output, options) + expect(await decryptBRC39(new Uint8Array(Buffer.concat(output.parts)), 'Caf\u00e9')).toEqual(document()) +}, 30000) +test('each new encryption owns fresh secure salt/nonce and exact canonical parameters', async () => { + const headers = [] + for (let index = 0; index < 2; index++) { + const output = stage() + await encryptBrc39StreamToQuarantine(plaintextSource(), 'password', output, options) + const frame = new Brc39StreamFrame({ ...policy, maximumChunkBytes: 65536 }) + frame.accept(new Uint8Array(Buffer.concat(output.parts))) + const header = frame.header()! + expect(header).toMatchObject(BRC39_STREAM_DEFAULT_KDF) + expect(header.salt).toHaveLength(32) + expect(header.nonce).toHaveLength(32) + const plaintext = Buffer.concat([...canonicalPortableChunks(document(), { maximumValueBytes: 1048576 })]) + const independent = AESGCM(new Uint8Array(plaintext), header.nonce, key) + const file = Buffer.concat(output.parts) + expect(file.subarray(97, file.length - 16)).toEqual(Buffer.from(independent.result)) + expect(file.subarray(file.length - 16)).toEqual(Buffer.from(independent.authenticationTag)) + headers.push(header) + } + expect(headers[0].salt).not.toEqual(headers[1].salt) + expect(headers[0].nonce).not.toEqual(headers[1].nonce) +}) +test.each([ + { ...options, kdf: { iterations: 6, memoryKiB: 131072, parallelism: 1 } }, + { ...options, kdf: { iterations: 7, memoryKiB: 131071, parallelism: 1 } }, + { ...options, policy: { ...policy, maximumIterations: 6 } }, + { ...options, policy: { ...policy, maximumFileBytes: 113 } }, + { ...options, maximumPasswordBytes: 3 } +])('invalid or weaker export work policy never derives or writes', async variant => { + const derive = jest.fn(async () => key.slice()) + backend!.deriveKey = derive + const output = stage() + await expect(encryptBrc39StreamToQuarantine(plaintextSource(), 'password', output, variant)).rejects.toThrow() + expect(derive).not.toHaveBeenCalled() + expect(output.parts).toHaveLength(0) + expect(output.discarded).toBe(1) +}) +test('export file admission includes prefix and reserved tag and refuses oversized chunks', async () => { + const bytes = Buffer.concat([...canonicalPortableChunks(document(), { maximumValueBytes: 1048576 })]) + const variants = [ + { ...options, policy: { ...policy, maximumFileBytes: bytes.length + 112 } }, + { ...options, policy: { ...policy, maximumChunkBytes: 30 } } + ] + for (const variant of variants) { + const output = stage() + const source = plaintextSource() + const validate = jest.fn(source.validateCompleted) + source.validateCompleted = validate + await expect(encryptBrc39StreamToQuarantine(source, 'password', output, variant)).rejects.toThrow() + expect(validate).not.toHaveBeenCalled() + expect(output.parts).toHaveLength(0) + expect(output.discarded).toBe(1) + } +}) +test('empty or semantically refused plaintext cannot finish an export', async () => { + const failure = new Error('synthetic incomplete source semantics') + const refused = plaintextSource() + refused.validateCompleted = async () => { + throw failure + } + const output = stage() + await expect(encryptBrc39StreamToQuarantine(refused, 'password', output, options)).rejects.toBe(failure) + expect(output.parts).toHaveLength(0) + expect(output.discarded).toBe(1) + const empty = stage() + await expect( + encryptBrc39StreamToQuarantine( + { chunks: chunks(new Uint8Array()), validateCompleted: async () => {} }, + 'password', + empty, + options + ) + ).rejects.toThrow('empty') + expect(empty.discarded).toBe(1) +}) +test('encryption source, progress and discard failures retain original causes', async () => { + const failure = new Error('synthetic export source failure') + const cleanup = new Error('synthetic export cleanup failure') + const output = stage() + output.discard = async () => { + throw cleanup + } + async function* broken() { + yield new Uint8Array([1]) + throw failure + } + try { + await encryptBrc39StreamToQuarantine( + { chunks: broken(), validateCompleted: async () => {} }, + 'password', + output, + options + ) + throw new Error('expected refusal') + } catch (error) { + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([failure, cleanup]) + expect((error as Error).cause).toBe(failure) + } + const progressOutput = stage() + await expect( + encryptBrc39StreamToQuarantine(plaintextSource(), 'password', progressOutput, { + ...options, + onProgress: () => { + throw failure + } + }) + ).rejects.toBe(failure) + expect(progressOutput.discarded).toBe(1) +}) +test('pending export output settles before cancellation cleanup and source cannot advance', async () => { + const controller = new AbortController() + const reason = new Error('synthetic export cancellation') + const output = stage() + let release!: () => void + let entered!: () => void + const writing = new Promise(resolve => { + entered = resolve + }) + const blocked = new Promise(resolve => { + release = resolve + }) + let chunksRead = 0 + const source = plaintextSource() + const original = source.chunks + async function* counted() { + for await (const chunk of original) { + chunksRead++ + yield chunk + } + } + source.chunks = counted() + output.appendUntrusted = async () => { + entered() + await blocked + } + const operation = encryptBrc39StreamToQuarantine(source, 'password', output, { + ...options, + signal: controller.signal + }) + const checked = expect(operation).rejects.toBe(reason) + await writing + controller.abort(reason) + try { + await new Promise(resolve => setImmediate(resolve)) + expect(output.discarded).toBe(0) + expect(chunksRead).toBe(0) + } finally { + release() + await checked + } + expect(output.discarded).toBe(1) +}) +test('generated bounded exports preserve independent codec bytes and reject corrupted complete tags', async () => { + const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) + const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) + const replayPath = process.env.FAST_CHECK_PATH + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ maxLength: 2048 }), + fc.integer({ min: 1, max: 128 }), + fc.boolean(), + async (payload, width, corrupt) => { + const value = document() + value.sourceStorage.storageName = 'synthetic-' + Buffer.from(payload).toString('base64') + const output = stage() + await encryptBrc39StreamToQuarantine(plaintextSource(value, width), 'Cafe\u0301', output, { + ...options, + policy: { ...policy, maximumChunkBytes: width } + }) + const file = new Uint8Array(Buffer.concat(output.parts)) + if (corrupt) { + file[file.length - 1] ^= 1 + await expect(decryptBRC39(file, 'Caf\u00e9')).rejects.toThrow() + } else expect(await decryptBRC39(file, 'Caf\u00e9')).toEqual(value) + expect(output.parts.every(part => part.length <= width)).toBe(true) + expect(output.discarded).toBe(0) + } + ), + { + numRuns: Number.isSafeInteger(requestedRuns) ? Math.max(300, requestedRuns) : 300, + seed: Number.isSafeInteger(requestedSeed) ? requestedSeed : 3242026, + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) +test.each([1, 15, 16, 17, 31, 33, 65, 97, 128])( + 'bounded decrypt width %i authenticates before semantics and reports complete bytes', + async width => { + const bytes = encrypted() + const quarantine = stage() + const progress: Array> = [] + const result = await decryptBrc39StreamToQuarantine(chunks(bytes, width), 'Cafe\u0301', quarantine, { + ...options, + onProgress: value => progress.push(value) + }) + expect(quarantine.validated).toBe(1) + expect(quarantine.discarded).toBe(0) + expect(Buffer.concat(quarantine.parts).toString()).toBe(JSON.stringify(document())) + expect(quarantine.parts.every(part => part.length <= width)).toBe(true) + expect(result).toEqual({ fileBytes: bytes.length, plaintextBytes: Buffer.byteLength(JSON.stringify(document())) }) + expect(Object.isFrozen(result)).toBe(true) + expect(progress.at(-1)).toEqual(result) + expect(progress.every(Object.isFrozen)).toBe(true) + } +) +test('existing materialized canonical export decrypts with real Argon2id/NFC and native GCM', async () => { + unregisterArgon2idBackend(backend!) + const bytes = new Uint8Array(await encryptBRC39(document(), 'Cafe\u0301')) + const quarantine = stage() + const result = await decryptBrc39StreamToQuarantine(chunks(bytes, 97), 'Caf\u00e9', quarantine, options) + expect(result.fileBytes).toBe(bytes.length) + expect(quarantine.validated).toBe(1) + expect(parseBRC38Json(Buffer.concat(quarantine.parts).toString())).toEqual(document()) + const rejected = stage() + await expect( + decryptBrc39StreamToQuarantine(chunks(bytes, 97), 'different password', rejected, options) + ).rejects.toThrow() + expect(rejected.validated).toBe(0) + expect(rejected.discarded).toBe(1) +}, 30000) +test.each(['tag', 'ciphertext', 'wrong key'])( + 'authentication failure for %s never validates or exposes a successful receipt', + async mode => { + const bytes = encrypted() + if (mode === 'tag') bytes[bytes.length - 1] ^= 1 + else if (mode === 'ciphertext') bytes[100] ^= 1 + else backend!.deriveKey = async () => new Uint8Array(32).fill(4) + const quarantine = stage() + await expect(decryptBrc39StreamToQuarantine(chunks(bytes), 'password', quarantine, options)).rejects.toThrow() + expect(quarantine.validated).toBe(0) + expect(quarantine.discarded).toBe(1) + expect(quarantine.parts).toHaveLength(0) + } +) +test.each([Buffer.from('{"brc":37}'), Buffer.from([0xff, 0xfe])])( + 'authenticated invalid semantics or UTF8 are discarded', + async plaintext => { + const quarantine = stage() + await expect( + decryptBrc39StreamToQuarantine(chunks(encrypted(plaintext)), 'password', quarantine, options) + ).rejects.toThrow() + expect(quarantine.validated).toBe(0) + expect(quarantine.discarded).toBe(1) + } +) +test('selected backend receives exact NFC bytes and encoded KDF parameters without trimming', async () => { + const derive = jest.fn(async (value: Readonly) => key.slice(0, value.hashLength)) + backend!.deriveKey = derive + await decryptBrc39StreamToQuarantine(chunks(encrypted()), ' Cafe\u0301 ', stage(), options) + expect(derive).toHaveBeenCalledTimes(1) + expect(derive.mock.calls[0]).toHaveLength(1) + const received = derive.mock.calls[0][0] + expect(received).toMatchObject({ iterations: 7, memorySize: 131072, parallelism: 1, hashLength: 32 }) + // The owned password array is wiped after the backend settles; capture the + // input while deriving to check encoding in a separate call. + let passwordBytes: Uint8Array | undefined + backend!.deriveKey = async value => { + passwordBytes = value.password.slice() + return key.slice() + } + await decryptBrc39StreamToQuarantine(chunks(encrypted()), ' Cafe\u0301 ', stage(), options) + expect(passwordBytes).toEqual(new TextEncoder().encode(' Caf\u00e9 ')) +}) +test('backend failure preserves exact identity without fallback and discards staging', async () => { + const failure = new Error('synthetic selected backend failure') + backend!.deriveKey = async () => { + throw failure + } + const quarantine = stage() + await expect(decryptBrc39StreamToQuarantine(chunks(encrypted()), 'password', quarantine, options)).rejects.toBe( + failure + ) + expect(quarantine.discarded).toBe(1) +}) + +test.each(['encrypt', 'decrypt'] as const)( + 'encoded password refusal wipes its owned bytes before %s cleanup', + async mode => { + const fill = jest.spyOn(Uint8Array.prototype, 'fill') + const derive = jest.spyOn(backend!, 'deriveKey') + const quarantine = stage() + const variant = { ...options, maximumPasswordBytes: 2 } + const operation = + mode === 'encrypt' + ? encryptBrc39StreamToQuarantine(plaintextSource(), '🙂', quarantine, variant) + : decryptBrc39StreamToQuarantine(chunks(encrypted()), '🙂', quarantine, variant) + await expect(operation).rejects.toThrow('encoded password exceeds') + expect(derive).not.toHaveBeenCalled() + expect(fill).toHaveBeenCalledWith(0) + const owned = fill.mock.contexts.find(bytes => bytes.length === 4) + expect(owned).toBeDefined() + expect(Array.from(owned!)).toEqual([0, 0, 0, 0]) + expect(quarantine.discarded).toBe(1) + } +) +test('invalid work/file/chunk/password policy refuses before backend derivation', async () => { + let derived = 0 + backend!.deriveKey = async () => { + derived++ + return key.slice() + } + const variants = [ + { ...options, policy: { ...policy, maximumIterations: 1 } }, + { ...options, policy: { ...policy, maximumFileBytes: 50 } }, + { ...options, policy: { ...policy, maximumChunkBytes: 30 } }, + { ...options, maximumPasswordBytes: 0 }, + { ...options, maximumPasswordBytes: 3 } + ] + for (const variant of variants) { + const quarantine = stage() + await expect(decryptBrc39StreamToQuarantine(chunks(encrypted()), 'password', quarantine, variant)).rejects.toThrow() + expect(quarantine.discarded).toBe(1) + } + expect(derived).toBe(0) +}) +test('cancellation waits for pending staging, closes source and keeps exact reason', async () => { + const controller = new AbortController() + const reason = new Error('synthetic cancellation') + const quarantine = stage() + let settle: (() => void) | undefined + let signalWrite: (() => void) | undefined + const entered = new Promise(resolve => { + signalWrite = resolve + }) + quarantine.appendUntrusted = async () => + new Promise(resolve => { + settle = resolve + signalWrite!() + }) + let closed = false + async function* source() { + try { + yield encrypted().slice(0, 128) + throw new Error('must not request another chunk') + } finally { + closed = true + } + } + let finished = false + const pending = decryptBrc39StreamToQuarantine(source(), 'password', quarantine, { + ...options, + signal: controller.signal + }).finally(() => { + finished = true + }) + await entered + controller.abort(reason) + await Promise.resolve() + expect(finished).toBe(false) + expect(quarantine.discarded).toBe(0) + settle!() + await expect(pending).rejects.toBe(reason) + expect(closed).toBe(true) + expect(quarantine.discarded).toBe(1) +}) +test('source and quarantine cleanup failures retain both exact causes', async () => { + const sourceFailure = new Error('synthetic source failure') + const cleanupFailure = new Error('synthetic quarantine cleanup failure') + async function* source() { + yield encrypted().slice(0, 128) + throw sourceFailure + } + const quarantine = stage() + quarantine.discard = async () => { + throw cleanupFailure + } + let caught: unknown + try { + await decryptBrc39StreamToQuarantine(source(), 'password', quarantine, options) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(AggregateError) + expect((caught as AggregateError).errors).toEqual([sourceFailure, cleanupFailure]) + expect((caught as Error).cause).toBe(sourceFailure) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts new file mode 100644 index 000000000..9b31cf0e0 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts @@ -0,0 +1,302 @@ +import { + createCipheriv, + createDecipheriv, + getCipherInfo, + randomBytes, + type CipherGCM, + type DecipherGCM +} from 'node:crypto' +import { toArray } from '@bsv/sdk/primitives/utils' +import { argon2id } from '../../utility/hashWasm' +import { runInSeries } from '../../utility/runInSeries' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' +import { + Brc39StreamFrame, + encodeBrc39StreamPrefix, + BRC39_STREAM_DEFAULT_KDF, + type Brc39StreamPolicy, + type Brc39StreamHeader, + type Brc39StreamKdf +} from './Brc39Frame' + +/** Host-owned private staging only. appendUntrusted must detach before its + * promise settles; no reader, import or activation may observe those bytes. + * validateAuthenticated must validate strict UTF8 and the complete BRC-38 + * document within its own bounded storage/validation implementation. */ +export interface Brc39StreamQuarantine { + appendUntrusted: (bytes: Uint8Array) => Promise + validateAuthenticated: () => Promise + discard: () => Promise +} +export interface Brc39NodeStreamOptions { + policy: Brc39StreamPolicy + maximumPasswordBytes: number + signal?: AbortSignal + onProgress?: (progress: Readonly<{ fileBytes: number; plaintextBytes: number }>) => void +} +interface CryptoOwnership { + cipher?: { destroy: () => void } + key?: Uint8Array +} +interface Session extends CryptoOwnership { + cipher?: DecipherGCM + plaintextBytes: number + fileBytes: number +} +function passwordLimit(maximumPasswordBytes: number): void { + if (!Number.isSafeInteger(maximumPasswordBytes) || maximumPasswordBytes < 1 || maximumPasswordBytes > 65536) + throw new RangeError('maximumPasswordBytes must be an integer from 1 to 65536') +} +async function deriveKey( + header: Brc39StreamHeader, + password: string, + maximumPasswordBytes: number +): Promise { + // Refuse impossible inputs before normalization/conversion. The temporary + // UTF8 conversion is bounded by four bytes per admitted UTF16 unit. + if (password.length > maximumPasswordBytes) + throw new SnapshotResourceLimitError('BRC-39 password exceeds the selected stream policy') + const normalized = password.normalize('NFC') + if (normalized.length > maximumPasswordBytes) + throw new SnapshotResourceLimitError('BRC-39 normalized password exceeds the selected stream policy') + const bytes = new Uint8Array(toArray(normalized, 'utf8')) + try { + if (bytes.length > maximumPasswordBytes) + throw new SnapshotResourceLimitError('BRC-39 encoded password exceeds the selected stream policy') + const result = await argon2id({ + password: bytes, + salt: header.salt, + iterations: header.iterations, + memorySize: header.memoryKiB, + parallelism: header.parallelism, + hashLength: 32, + outputType: 'binary' + }) + if (!(result instanceof Uint8Array) || result.length !== 32) throw new TypeError('Invalid BRC-39 derived key') + return new Uint8Array(result) + } finally { + bytes.fill(0) + } +} +function release(session: CryptoOwnership): void { + const { cipher, key } = session + session.cipher = undefined + session.key = undefined + try { + cipher?.destroy() + } finally { + key?.fill(0) + } +} +async function discard(quarantine: Pick, original: unknown): Promise { + try { + await quarantine.discard() + } catch (cleanup) { + throw new AggregateError([original, cleanup], 'BRC-39 processing and quarantine cleanup failed', { + cause: original + }) + } + throw original +} + +/** Complete semantics must be checked by the coherent BRC-38 producer. This + * final check runs after all chunks, before a tag or success can be published. + * A crypto success receipt alone does not establish source/archive semantics. */ +export interface Brc39StreamPlaintext { + chunks: AsyncIterable + validateCompleted: () => Promise + /** Optional backwards-compatible owned-source cleanup. Must be idempotent + * and wait for pending source I/O. Called before publication and on failure. */ + close?: () => Promise +} +/** Host-owned private output. appendUntrusted must detach before settling and + * must not expose a partial file. Only the successful receipt permits the host + * to complete its own durable file transaction. */ +export interface Brc39StreamOutput { + appendUntrusted: (bytes: Uint8Array) => Promise + discard: () => Promise +} +export interface Brc39NodeEncryptOptions extends Brc39NodeStreamOptions { + /** Defaults to the existing canonical strength; weaker new exports refuse. */ + kdf?: Brc39StreamKdf +} +interface EncryptSession extends CryptoOwnership { + cipher?: CipherGCM + plaintextBytes: number + fileBytes: number +} +async function closeFailedSource(source: Brc39StreamPlaintext, original: unknown): Promise { + try { + await source.close?.() + return original + } catch (cleanup) { + if (cleanup === original) return original + return new AggregateError([original, cleanup], 'BRC-39 encryption and source cleanup failed', { cause: original }) + } +} +function* splitOutput(bytes: Uint8Array, maximumChunkBytes: number): Generator { + for (let offset = 0; offset < bytes.length; offset += maximumChunkBytes) + yield bytes.slice(offset, offset + maximumChunkBytes) +} + +/** Node-only bounded encryption component using native secure random + * salt/nonce and GCM, plus the existing Argon2id selection/NFC rules. Its output + * is the standard envelope, without AAD or an additional wrapper. One source + * chunk is consumed at a time; all writes, including prefix/tag, obey the same + * byte ceiling and private-staging backpressure. The source must independently + * validate complete semantics; hosts own durable file activation and adapters. */ +export async function encryptBrc39StreamToQuarantine( + source: Brc39StreamPlaintext, + password: string, + output: Brc39StreamOutput, + options: Brc39NodeEncryptOptions +): Promise> { + const { maximumPasswordBytes, signal, onProgress } = options + const session: EncryptSession = { plaintextBytes: 0, fileBytes: 0 } + try { + passwordLimit(maximumPasswordBytes) + signal?.throwIfAborted() + const policy = Object.freeze({ ...options.policy }) + const kdf = { ...(options.kdf ?? BRC39_STREAM_DEFAULT_KDF) } + const salt = new Uint8Array(randomBytes(32)) + const nonce = new Uint8Array(randomBytes(32)) + const prefix = encodeBrc39StreamPrefix(kdf, salt, nonce, policy) + session.key = await deriveKey({ ...kdf, salt, nonce }, password, maximumPasswordBytes) + signal?.throwIfAborted() + session.cipher = createCipheriv('aes-256-gcm', session.key, nonce, { authTagLength: 16 }) + const write = async (bytes: Uint8Array): Promise => { + await runInSeries(splitOutput(bytes, policy.maximumChunkBytes), async chunk => { + signal?.throwIfAborted() + await output.appendUntrusted(chunk) + session.fileBytes += chunk.length + signal?.throwIfAborted() + }) + } + await write(prefix) + const consume = async (input: Uint8Array): Promise> => { + signal?.throwIfAborted() + if (!(input instanceof Uint8Array)) throw new TypeError('BRC-39 plaintext stream requires byte chunks') + if (input.length > policy.maximumChunkBytes) + throw new SnapshotResourceLimitError('BRC-39 plaintext chunk exceeds the selected stream policy') + if (input.length > policy.maximumFileBytes - session.fileBytes - 16) + throw new SnapshotResourceLimitError('BRC-39 export exceeds the selected file policy') + if (session.cipher === undefined) throw new Error('BRC-39 encryption context is closed') + const ciphertext = session.cipher.update(input) + if (ciphertext.length !== input.length) throw new Error('Selected GCM backend buffered ciphertext') + await write(new Uint8Array(ciphertext)) + session.plaintextBytes += input.length + return Object.freeze({ fileBytes: session.fileBytes, plaintextBytes: session.plaintextBytes }) + } + async function* operations() { + for await (const chunk of source.chunks) yield consume(chunk) + } + for await (const progress of operations()) onProgress?.(progress) + signal?.throwIfAborted() + if (session.plaintextBytes === 0) throw new TypeError('BRC-39 plaintext stream is empty') + if (session.cipher.final().length !== 0) throw new Error('Selected GCM backend buffered final ciphertext') + const tag = new Uint8Array(session.cipher.getAuthTag()) + release(session) + signal?.throwIfAborted() + await source.validateCompleted() + signal?.throwIfAborted() + await source.close?.() + signal?.throwIfAborted() + await write(tag) + const result = Object.freeze({ fileBytes: session.fileBytes, plaintextBytes: session.plaintextBytes }) + onProgress?.(result) + return result + } catch (error) { + let failure = error + try { + release(session) + } catch (cleanup) { + failure = new AggregateError([error, cleanup], 'BRC-39 encryption and crypto cleanup failed', { cause: error }) + } + return await discard(output, await closeFailedSource(source, failure)) + } +} +async function start( + session: Session, + header: Brc39StreamHeader, + password: string, + maximumPasswordBytes: number, + signal: AbortSignal | undefined +): Promise { + if (getCipherInfo('aes-256-gcm', { keyLength: 32, ivLength: header.nonce.length }) === undefined) + throw new Error('Selected Node AES-GCM backend cannot process this BRC-39 nonce length') + signal?.throwIfAborted() + session.key = await deriveKey(header, password, maximumPasswordBytes) + // Cancellation waits for the owned derivation to settle before releasing it. + signal?.throwIfAborted() + session.cipher = createDecipheriv('aes-256-gcm', session.key, header.nonce, { authTagLength: 16 }) +} +/** Node-only authenticated streaming into a host-owned private quarantine. + * Uses the existing Argon2id backend selection/NFC encoding and native GCM. + * Source chunks and provisional plaintext stay bounded with serial staging + * backpressure. No document/file byte array is assembled by this component. + * A successful result follows GCM completion and the quarantine's independent + * semantic validation; durable token/activation/platform integration remains + * the caller's responsibility. Unsupported native nonce lengths refuse before + * KDF; the existing materialized codec retains its accepted inputs unchanged. + */ +export async function decryptBrc39StreamToQuarantine( + source: AsyncIterable, + password: string, + quarantine: Brc39StreamQuarantine, + options: Brc39NodeStreamOptions +): Promise> { + const { maximumPasswordBytes, signal, onProgress } = options + const session: Session = { fileBytes: 0, plaintextBytes: 0 } + try { + passwordLimit(maximumPasswordBytes) + const frame = new Brc39StreamFrame(options.policy) + signal?.throwIfAborted() + async function consume(input: Uint8Array): Promise> { + signal?.throwIfAborted() + const ciphertext = frame.accept(input) + session.fileBytes += input.length + if (session.cipher === undefined) { + const header = frame.header() + if (header !== undefined) await start(session, header, password, maximumPasswordBytes, signal) + } + await runInSeries(ciphertext, async chunk => { + signal?.throwIfAborted() + if (session.cipher === undefined) throw new Error('BRC-39 ciphertext arrived before a complete header') + const plaintext = session.cipher.update(chunk) + if (plaintext.length !== chunk.length) throw new Error('Selected GCM backend buffered provisional plaintext') + await quarantine.appendUntrusted(new Uint8Array(plaintext)) + session.plaintextBytes += plaintext.length + signal?.throwIfAborted() + }) + return Object.freeze({ fileBytes: session.fileBytes, plaintextBytes: session.plaintextBytes }) + } + async function* operations() { + for await (const chunk of source) yield consume(chunk) + } + for await (const progress of operations()) { + onProgress?.(progress) + } + signal?.throwIfAborted() + const end = frame.finish() + if (session.cipher === undefined) throw new TypeError('BRC-39 stream has no decryptor') + session.cipher.setAuthTag(end.tag) + const final = session.cipher.final() + if (final.length !== 0 || session.plaintextBytes !== end.ciphertextBytes) + throw new Error('Selected GCM backend did not preserve the complete plaintext length') + release(session) + signal?.throwIfAborted() + await quarantine.validateAuthenticated() + signal?.throwIfAborted() + return Object.freeze({ fileBytes: end.fileBytes, plaintextBytes: session.plaintextBytes }) + } catch (error) { + try { + release(session) + } catch (cleanup) { + return await discard( + quarantine, + new AggregateError([error, cleanup], 'BRC-39 processing and crypto cleanup failed', { cause: error }) + ) + } + return await discard(quarantine, error) + } +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts new file mode 100644 index 000000000..f07093724 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts @@ -0,0 +1,57 @@ +import fc from 'fast-check' +import { canonicalPortableChunks } from './CanonicalPortableChunks' + +const MIN_PROPERTY_RUNS = 300 +fc.configureGlobal({ + numRuns: Math.max(MIN_PROPERTY_RUNS, Number(process.env.FAST_CHECK_NUM_RUNS ?? MIN_PROPERTY_RUNS)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true +}) + +type Value = string | number | boolean | Value[] | { [key: string]: Value } +function reference(value: Value): string { + if (typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return '[' + value.map(reference).join(',') + ']' + return ( + '{' + + Object.keys(value) + .sort((first, second) => Number(first > second) - Number(first < second)) + .map(key => JSON.stringify(key) + ':' + reference(value[key])) + .join(',') + + '}' + ) +} +const scalar = fc.oneof( + fc.boolean(), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc + .array(fc.integer({ min: 0, max: 0x10f7ff }), { maxLength: 24 }) + .map(points => String.fromCodePoint(...points.map(point => (point < 0xd800 ? point : point + 0x800)))) +) +const values = fc.letrec<{ value: Value }>(tie => ({ + value: fc.oneof( + { depthSize: 'small' }, + scalar, + fc.array(tie('value'), { maxLength: 6 }), + fc.dictionary(fc.string({ maxLength: 8 }), tie('value'), { maxKeys: 6 }) + ) +})).value + +test('generated JSON values preserve exact canonical bytes and per-chunk bounds', () => { + fc.assert( + fc.property(values, fc.integer({ min: 64, max: 1024 }), (value, maximumChunkBytes) => { + const chunks = [...canonicalPortableChunks(value, { maximumValueBytes: 16777216, maximumChunkBytes })] + expect(chunks.every(chunk => chunk.byteLength > 0 && chunk.byteLength <= maximumChunkBytes)).toBe(true) + expect(Buffer.concat(chunks).toString('utf8')).toBe(reference(value)) + }), + { + numRuns: Math.max(300, Number(process.env.FAST_CHECK_NUM_RUNS ?? 300)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.test.ts new file mode 100644 index 000000000..c80b7cbb9 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.test.ts @@ -0,0 +1,145 @@ +import { canonicalPortableChunks } from './CanonicalPortableChunks' +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' + +const options = { maximumValueBytes: 16777216, maximumChunkBytes: 64 } +function encoded(value: unknown, chunkBytes = 64): string { + const chunks = [...canonicalPortableChunks(value, { ...options, maximumChunkBytes: chunkBytes })] + expect(chunks.every(chunk => chunk.byteLength > 0 && chunk.byteLength <= chunkBytes)).toBe(true) + return Buffer.concat(chunks).toString('utf8') +} + +test('preserves the RFC8785 number spelling and UTF16 property order', () => { + const value = { + numbers: [Number('333333333.33333329'), 1e30, 4.5, 0.002, 1e-27, -0, 5e-324], + '\ud83d\ude00': 'emoji', + '\ufb33': 'hebrew', + '\u20ac': 'euro', + '1': 'one', + '\u0080': 'control', + '\u00f6': 'latin', + '\r': 'return' + } + expect(encoded(value)).toBe( + '{"\\r":"return","1":"one","numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27,0,5e-324],"\u0080":"control","ö":"latin","€":"euro","😀":"emoji","דּ":"hebrew"}' + ) +}) + +test.each([64, 65, 127, 1024, 65536])('preserves exact escaped bytes across %i byte chunks', size => { + const text = '😀"\\\u0000\n\t\b\f\r/é名字'.repeat(3000) + expect(encoded({ text }, size)).toBe('{"text":' + JSON.stringify(text) + '}') +}) + +test('preserves distinct composed and decomposed Unicode values', () => { + expect(encoded({ decomposed: 'e\u0301', composed: 'é' })).toBe('{"composed":"é","decomposed":"é"}') +}) + +test.each([null, undefined, NaN, Infinity, -Infinity, 1n, new Date(), new Uint8Array(2)])( + 'refuses unsupported portable values before yielding output: %p', + value => { + expect(() => canonicalPortableChunks(value, options).next()).toThrow(TypeError) + } +) + +test.each(['\ud800', '\udfff', 'prefix\ud800suffix', '\udfff\ud800'])('refuses lone surrogates: %p', text => { + expect(() => canonicalPortableChunks({ text }, options).next()).toThrow(TypeError) + expect(() => canonicalPortableChunks({ [text]: 'value' }, options).next()).toThrow(TypeError) +}) + +test('refuses nulls, undefined and holes instead of dropping array positions', () => { + const hole: unknown[] = [] + hole.length = 1 + for (const value of [[null], [undefined], hole, { missing: undefined }, { empty: null }]) + expect(() => canonicalPortableChunks(value, options).next()).toThrow(TypeError) +}) + +test('does not invoke getters while inspecting portable objects or arrays', () => { + const get = jest.fn(() => 'untrusted') + const object = Object.defineProperty({}, 'value', { get, enumerable: true }) + const array = Object.defineProperty([], '0', { get, enumerable: true }) + for (const value of [object, array]) expect(() => canonicalPortableChunks(value, options).next()).toThrow(TypeError) + expect(get).not.toHaveBeenCalled() +}) + +test('refuses cycles but permits repeated detached values', () => { + const cycle: Record = {} + cycle.self = cycle + expect(() => canonicalPortableChunks(cycle, options).next()).toThrow(TypeError) + const same = { a: 1 } + expect(encoded([same, same])).toBe('[{"a":1},{"a":1}]') +}) + +test('keeps prototype-like property names as ordinary portable data', () => { + const value: unknown = JSON.parse('{"__proto__":{"x":1},"constructor":"value","toString":false}') + expect(encoded(value)).toBe('{"__proto__":{"x":1},"constructor":"value","toString":false}') +}) + +test('detaches every value before yielding so caller mutations do not change later output', () => { + const original = { prefix: 'x'.repeat(300), suffix: { values: ['before', false, 0] } } + const expected = encoded(original) + const iterator = canonicalPortableChunks(original, options) + const first = iterator.next() + expect(first.done).toBe(false) + original.prefix = 'changed' + original.suffix.values.splice(0, 3, 'after') + const chunks = [first.value as Uint8Array, ...iterator] + expect(Buffer.concat(chunks).toString('utf8')).toBe(expected) +}) + +test('never reuses buffers already returned to the caller', () => { + const text = 'abc'.repeat(2000) + const chunks = [...canonicalPortableChunks({ text }, options)] + expect(new Set(chunks.map(chunk => chunk.buffer)).size).toBe(chunks.length) + const before = Buffer.concat(chunks.slice(1)) + chunks[0].fill(0) + expect(Buffer.concat(chunks.slice(1))).toEqual(before) +}) + +test('enforces allocation and nesting limits before yielding', () => { + expect(() => canonicalPortableChunks('x', { maximumValueBytes: 65 }).next()).toThrow(SnapshotResourceLimitError) + expect([...canonicalPortableChunks('x', { maximumValueBytes: 66 })]).toHaveLength(1) + expect(() => canonicalPortableChunks([true], { maximumValueBytes: 127 }).next()).toThrow(SnapshotResourceLimitError) + expect(encoded([true])).toBe('[true]') + let deep: unknown = true + for (let index = 0; index < 65; index++) deep = [deep] + expect(() => canonicalPortableChunks(deep, options).next()).toThrow(SnapshotResourceLimitError) +}) + +test('admits large structured sync maps using the allocation budget rather than a small fixed member limit', () => { + const idMap: Record = {} + for (let index = 0; index < 1000; index++) idMap[String(index)] = 1000 - index + const expected = + '{"idMap":{' + + Object.keys(idMap) + .sort((a, b) => Number(a > b) - Number(a < b)) + .map(key => JSON.stringify(key) + ':' + idMap[key]) + .join(',') + + '}}' + expect(encoded({ idMap })).toBe(expected) + expect(() => canonicalPortableChunks({ idMap }, { maximumValueBytes: 1000 }).next()).toThrow( + SnapshotResourceLimitError + ) +}) + +test.each([0, -1, 0.5, NaN, 16777217])('rejects invalid value budgets: %p', maximumValueBytes => { + expect(() => canonicalPortableChunks(true, { maximumValueBytes }).next()).toThrow(RangeError) +}) +test.each([0, 63, 65537, 1.5, NaN])('rejects invalid chunk budgets: %p', maximumChunkBytes => { + expect(() => canonicalPortableChunks(true, { maximumValueBytes: 1024, maximumChunkBytes }).next()).toThrow(RangeError) +}) + +test('preserves the exact cancellation reason before work and between output chunks', () => { + const reason = new Error('synthetic caller cancellation') + const early = new AbortController() + early.abort(reason) + expect(() => canonicalPortableChunks('x', { ...options, signal: early.signal }).next()).toThrow(reason) + const later = new AbortController() + const iterator = canonicalPortableChunks('x'.repeat(300), { ...options, signal: later.signal }) + expect(iterator.next().done).toBe(false) + later.abort(reason) + try { + iterator.next() + throw new Error('expected cancellation') + } catch (error) { + expect(error).toBe(reason) + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.ts b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.ts new file mode 100644 index 000000000..1d2a8a808 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.ts @@ -0,0 +1,162 @@ +import { SnapshotResourceLimitError } from '../snapshot/SnapshotResourceLimitError' + +type Value = string | number | boolean | Value[] | { [key: string]: Value } +export interface CanonicalPortableChunkOptions { + /** Allocation charge for one detached value, not the whole wallet. */ + maximumValueBytes: number + /** A bounded output buffer; defaults to 65,536 bytes. */ + maximumChunkBytes?: number + signal?: AbortSignal +} +interface State { + remaining: number + active: Set + signal: AbortSignal | undefined +} +interface DetachedValue { + value: Value +} +function invalid(): never { + throw new TypeError('Canonical portable values require finite JSON scalars without null or absent array entries') +} +function charge(state: State, bytes: number): void { + state.signal?.throwIfAborted() + state.remaining -= bytes + if (state.remaining < 0) + throw new SnapshotResourceLimitError('Canonical portable value exceeds its allocation budget') +} +function unicode(value: string, state: State): void { + charge(state, 64 + 2 * value.length) + for (let index = 0; index < value.length; index++) { + if (index % 1024 === 0) state.signal?.throwIfAborted() + const unit = value.charCodeAt(index) + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++index) + if (!(next >= 0xdc00 && next <= 0xdfff)) invalid() + } else if (unit >= 0xdc00 && unit <= 0xdfff) invalid() + } +} +function child(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor === undefined || !Object.hasOwn(descriptor, 'value')) invalid() + return descriptor.value +} +function detachedObject(value: object, depth: number, state: State): { [key: string]: Value } { + const prototype: unknown = Object.getPrototypeOf(value) + if (prototype !== null && prototype !== Object.prototype) invalid() + const result: { [key: string]: Value } = Object.create(null) + // Stop before allocating an unbounded property-name array or invoking getters. + for (const key in value) { + if (!Object.hasOwn(value, key)) continue + unicode(key, state) + result[key] = detach(child(value, key), depth + 1, state).value + } + return result +} +function detach(value: unknown, depth: number, state: State): DetachedValue { + state.signal?.throwIfAborted() + if (depth > 64) throw new SnapshotResourceLimitError('Canonical portable nesting exceeds 64 levels') + if (typeof value === 'string') { + unicode(value, state) + return { value } + } + charge(state, 64) + if (typeof value === 'boolean') return { value } + if (typeof value === 'number' && Number.isFinite(value)) return { value } + if (typeof value !== 'object' || value === null) invalid() + if (state.active.has(value)) invalid() + state.active.add(value) + try { + if (!Array.isArray(value)) return { value: detachedObject(value, depth, state) } + // Every child costs at least 64 bytes; refuse impossible lengths before + // allocating the detached array, with the caller's actual remaining budget. + if (value.length > Math.floor(state.remaining / 64)) + throw new SnapshotResourceLimitError('Canonical portable array exceeds its allocation budget') + const result: Value[] = [] + for (let index = 0; index < value.length; index++) + result.push(detach(child(value, String(index)), depth + 1, state).value) + return { value: result } + } finally { + state.active.delete(value) + } +} +function* stringParts(value: string, width: number): Generator { + yield '"' + let offset = 0 + while (offset < value.length) { + let end = Math.min(value.length, offset + width) + const tail = value.charCodeAt(end - 1) + if (end < value.length && tail >= 0xd800 && tail <= 0xdbff) end++ + // ECMAScript escaping on a bounded window preserves JCS scalar spelling. + yield JSON.stringify(value.slice(offset, end)).slice(1, -1) + offset = end + } + yield '"' +} +function compareUtf16(first: string, second: string): number { + if (first < second) return -1 + if (first > second) return 1 + return 0 +} +function* parts(value: Value, width: number): Generator { + if (typeof value === 'string') yield* stringParts(value, width) + else if (typeof value === 'number' || typeof value === 'boolean') yield JSON.stringify(value) + else if (Array.isArray(value)) { + yield '[' + for (let index = 0; index < value.length; index++) { + if (index !== 0) yield ',' + yield* parts(value[index], width) + } + yield ']' + } else { + yield '{' + // RFC8785 orders raw UTF16 units, independently of locale. + const keys = Object.keys(value).sort(compareUtf16) + for (let index = 0; index < keys.length; index++) { + if (index !== 0) yield ',' + yield* stringParts(keys[index], width) + yield ':' + yield* parts(value[keys[index]], width) + } + yield '}' + } +} +/** Serialization of ONE bounded, detached portable value. This does + * not capture a coherent source, establish table closure, normalize stored + * histories, stream native blobs or produce a complete BRC-38 archive alone. + * No full escaped string or document byte array is retained. Callers must not + * publish partially consumed output as a completed archive. + */ +export function* canonicalPortableChunks( + input: unknown, + options: CanonicalPortableChunkOptions +): Generator { + const { maximumValueBytes, maximumChunkBytes = 65536, signal } = options + if (!Number.isSafeInteger(maximumValueBytes) || maximumValueBytes < 1 || maximumValueBytes > 16777216) + throw new RangeError('maximumValueBytes must be an integer from 1 to 16777216') + if (!Number.isSafeInteger(maximumChunkBytes) || maximumChunkBytes < 64 || maximumChunkBytes > 65536) + throw new RangeError('maximumChunkBytes must be an integer from 64 to 65536') + const value = detach(input, 0, { remaining: maximumValueBytes, active: new Set(), signal }).value + const encoder = new TextEncoder() + let buffer = new Uint8Array(maximumChunkBytes), + used = 0 + for (const part of parts(value, Math.floor(maximumChunkBytes / 6) - 1)) { + signal?.throwIfAborted() + const bytes = encoder.encode(part) + let offset = 0 + while (offset < bytes.length) { + const count = Math.min(bytes.length - offset, maximumChunkBytes - used) + buffer.set(bytes.subarray(offset, offset + count), used) + used += count + offset += count + if (used === maximumChunkBytes) { + yield buffer + signal?.throwIfAborted() + buffer = new Uint8Array(maximumChunkBytes) + used = 0 + } + } + } + signal?.throwIfAborted() + if (used !== 0) yield buffer.slice(0, used) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/node.ts b/packages/wallet/wallet-toolbox/src/storage/portable/node.ts new file mode 100644 index 000000000..b6ab1d77d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/node.ts @@ -0,0 +1,5 @@ +/** Node adapters for dedicated SQL readers, native crypto and private files. */ +export * from './stream' +export * from './Brc38KnexSource' +export * from './Brc39StreamNode' +export * from './Brc39PrivateFileNode' diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/stream.ts b/packages/wallet/wallet-toolbox/src/storage/portable/stream.ts new file mode 100644 index 000000000..e1130b206 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/stream.ts @@ -0,0 +1,6 @@ +/** Bounded portable codecs. Hosts provide coherent sources and private staging. */ +export * from './CanonicalPortableChunks' +export * from './Brc38PackedRow' +export * from './Brc38Stream' +export * from './Brc38JsonStream' +export * from './Brc39Frame' diff --git a/packages/wallet/wallet-toolbox/test/consumer/portableStreams.ts b/packages/wallet/wallet-toolbox/test/consumer/portableStreams.ts new file mode 100644 index 000000000..adcea31fe --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/consumer/portableStreams.ts @@ -0,0 +1,62 @@ +import { + canonicalPortableChunks, + createBrc38Stream, + readBrc38JsonStream, + type Brc38StreamSource, + type Brc38StreamOptions, + type Brc38JsonStagingSink, + type Brc38JsonStreamOptions, + type Brc38JsonStreamResult +} from '@bsv/wallet-toolbox/portable' +import { + createBrc39NodeFileQuarantine, + encryptBrc39StreamToQuarantine, + decryptBrc39StreamToQuarantine, + type Brc39NodeFilePolicy, + type Brc39NodeEncryptOptions, + type Brc39NodeStreamOptions, + type Brc39StreamOutput +} from '@bsv/wallet-toolbox/portable/node' + +type IsAny = 0 extends 1 & T ? true : false +export const canonicalHasImplicitAny: IsAny> = false +export const parserHasImplicitAny: IsAny>> = false +export const quarantineHasImplicitAny: IsAny>> = false + +// Declaration-only consumer fixtures. Hosts still own durable publication/import. +export async function encryptPortableSource( + source: Brc38StreamSource, + sourceOptions: Brc38StreamOptions, + password: string, + privateOutput: Brc39StreamOutput, + encryptionOptions: Brc39NodeEncryptOptions +): Promise> { + const stream = await createBrc38Stream(source, sourceOptions) + return encryptBrc39StreamToQuarantine(stream, password, privateOutput, encryptionOptions) +} + +export async function validatePrivateArchive( + trustedParent: string, + encrypted: AsyncIterable, + password: string, + encryptionOptions: Brc39NodeStreamOptions, + filePolicy: Brc39NodeFilePolicy, + sink: Brc38JsonStagingSink, + jsonOptions: Brc38JsonStreamOptions +): Promise { + let validated: Brc38JsonStreamResult | undefined + const quarantine = await createBrc39NodeFileQuarantine( + trustedParent, + async chunks => { + validated = await readBrc38JsonStream(chunks, sink, jsonOptions) + }, + filePolicy + ) + try { + await decryptBrc39StreamToQuarantine(encrypted, password, quarantine, encryptionOptions) + if (validated === undefined) throw new Error('Archive validation did not complete') + return validated + } finally { + await quarantine.discard() + } +} diff --git a/packages/wallet/wallet-toolbox/test/consumer/tsconfig.json b/packages/wallet/wallet-toolbox/test/consumer/tsconfig.json index fd19be7a4..84a4f2e48 100644 --- a/packages/wallet/wallet-toolbox/test/consumer/tsconfig.json +++ b/packages/wallet/wallet-toolbox/test/consumer/tsconfig.json @@ -9,5 +9,5 @@ "noEmit": true, "types": ["node"] }, - "files": ["hashWasm.cts", "hashWasm.mts"] + "files": ["hashWasm.cts", "hashWasm.mts", "portableStreams.ts"] } diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 0269355ad..101ed8f3c 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -214,7 +214,7 @@ test('additional package-relative fixture inputs select their target without rep ) const canonical = buildMutationTargets(REPOSITORY_ROOT) - assert.equal(Object.keys(canonical).length, 49) + assert.equal(Object.keys(canonical).length, 57) assert.deepEqual(canonical['wallet-retained-snapshot'].additionalInputs, [ 'test/utils/snapshotRelationFixtures.ts', 'test/utils/snapshotCertificateFixtures.ts', @@ -379,6 +379,48 @@ test('adaptive paging owns its complete controller without removing the existing assert.ok(affected.includes('wallet-snapshot-sync')) }) +test('portable streaming keeps every complete source module and original behavioral suite', () => { + const targets = buildMutationTargets(REPOSITORY_ROOT) + const modules = [ + ['wallet-portable-canonical-chunks', 'CanonicalPortableChunks'], + ['wallet-portable-packed-row', 'Brc38PackedRow'], + ['wallet-portable-source-stream', 'Brc38Stream'], + ['wallet-portable-knex-source', 'Brc38KnexSource'], + ['wallet-portable-json-stream', 'Brc38JsonStream'], + ['wallet-portable-brc39-frame', 'Brc39Frame'], + ['wallet-portable-brc39-node', 'Brc39StreamNode'], + ['wallet-portable-private-file', 'Brc39PrivateFileNode'] + ] + const originalSuites = [ + 'Brc38JsonStream.property.test.ts', + 'Brc38JsonStream.test.ts', + 'Brc38KnexSource.test.ts', + 'Brc38PackedRow.test.ts', + 'Brc38Stream.test.ts', + 'Brc39Frame.property.test.ts', + 'Brc39Frame.test.ts', + 'Brc39PrivateFileNode.test.ts', + 'Brc39StreamNode.test.ts', + 'CanonicalPortableChunks.property.test.ts', + 'CanonicalPortableChunks.test.ts' + ].map(name => `/src/storage/portable/${name}`) + for (const [id, source] of modules) { + const target = targets[id] + assert.deepEqual(target.mutate, [`src/storage/portable/${source}.ts`]) + assert.deepEqual(target.runnerOptions.jest.config.testMatch, originalSuites) + assert.deepEqual(target.additionalInputs, [ + 'src/storage/portable/index.ts', + 'src/storage/portable/stream.ts', + 'src/storage/portable/node.ts' + ]) + assert.ok( + selectAffectedMutationTargets(targets, [ + `packages/wallet/wallet-toolbox/src/storage/portable/${source}.ts` + ]).includes(id) + ) + } +}) + test('snapshot sync owns every inherited manager region and complete primary selection', () => { const target = buildMutationTargets(REPOSITORY_ROOT)['wallet-snapshot-sync'] const file = 'src/storage/WalletStorageManager.ts' diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index dd7e6983b..bfc00f697 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 49) + assert.equal(result.summary.propertySuites, 57) assert.equal(result.summary.propertyPackages, 31) assert.equal(result.summary.propertyExcludedPackages, 5) assert.equal(result.summary.propertyClassifiedPackages, 36) - assert.equal(result.summary.mutationTargets, 49) + assert.equal(result.summary.mutationTargets, 57) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) From b4702257639fb4ea96fbede67f51d4fc443cf65b Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 12:22:56 -0700 Subject: [PATCH 111/127] fix(wallet): resolve portable types and erase owned stream keys --- docs/guides/wallet-data-portability.md | 4 +++ packages/wallet/wallet-toolbox/package.json | 10 +++++++ .../storage/portable/Brc39StreamNode.test.ts | 28 +++++++++++++++++++ .../src/storage/portable/Brc39StreamNode.ts | 6 +++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/guides/wallet-data-portability.md b/docs/guides/wallet-data-portability.md index 68986ebe4..25f42ec09 100644 --- a/docs/guides/wallet-data-portability.md +++ b/docs/guides/wallet-data-portability.md @@ -84,6 +84,10 @@ Decryption writes only into isolated quarantine. GCM authentication must precede These entry points bound each component's admitted work and buffers. They do not establish native allocator/RSS/IPC bounds, hard database/WAL/directory quotas, durable occupied-target restore, replicated remote export destinations, or physical mobile qualification. Those remain mandatory in the full #544 program. No pending intermediate API is a released production guarantee. +A selected host Argon2id backend must return a fresh owned 32-byte key buffer +for each streaming operation. The Node streaming adapter consumes and wipes +that buffer after success or failure; a backend must not reuse it between calls. + ## Coverage and limits The implementation exports one `user`, its `sourceStorage` metadata and 13 diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 61670bce8..5cf7fa12f 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -139,5 +139,15 @@ "@bsv/sdk": { "optional": false } + }, + "typesVersions": { + "*": { + "portable": [ + "out/src/storage/portable/stream.d.ts" + ], + "portable/node": [ + "out/src/storage/portable/node.d.ts" + ] + } } } diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts index e7a004eb6..474596f25 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts @@ -481,6 +481,34 @@ test('backend failure preserves exact identity without fallback and discards sta expect(quarantine.discarded).toBe(1) }) +test.each([ + ['encrypt', 'completed'], + ['encrypt', 'failed'], + ['decrypt', 'completed'], + ['decrypt', 'failed'] +] as const)('the original derived key is erased after %s %s settlement', async (mode, outcome) => { + const derived = key.slice() + backend!.deriveKey = async () => derived + const quarantine = stage() + const failure = new Error('synthetic private write failure') + if (outcome === 'failed') + quarantine.appendUntrusted = async () => { + throw failure + } + const operation = + mode === 'encrypt' + ? encryptBrc39StreamToQuarantine(plaintextSource(), 'password', quarantine, options) + : decryptBrc39StreamToQuarantine(chunks(encrypted()), 'password', quarantine, options) + if (outcome === 'failed') { + await expect(operation).rejects.toBe(failure) + expect(quarantine.discarded).toBe(1) + } else { + await expect(operation).resolves.toMatchObject({ plaintextBytes: expect.any(Number) }) + } + expect(derived).toEqual(new Uint8Array(32)) + expect(key).toEqual(new Uint8Array(32).fill(17)) +}) + test.each(['encrypt', 'decrypt'] as const)( 'encoded password refusal wipes its owned bytes before %s cleanup', async mode => { diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts index 9b31cf0e0..8fb891f1f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts @@ -30,6 +30,8 @@ export interface Brc39StreamQuarantine { } export interface Brc39NodeStreamOptions { policy: Brc39StreamPolicy + /** A selected host Argon2id backend must return a fresh owned key buffer. + * Streaming consumes that buffer and wipes it when the operation settles. */ maximumPasswordBytes: number signal?: AbortSignal onProgress?: (progress: Readonly<{ fileBytes: number; plaintextBytes: number }>) => void @@ -73,7 +75,9 @@ async function deriveKey( outputType: 'binary' }) if (!(result instanceof Uint8Array) || result.length !== 32) throw new TypeError('Invalid BRC-39 derived key') - return new Uint8Array(result) + // Own the returned derivation buffer directly. A second copy would leave + // the original key outside the session's awaited erasure boundary. + return result } finally { bytes.fill(0) } From 90c1e8bb5be557b96ba718e8fbc4d321686a0670 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 12:56:20 -0700 Subject: [PATCH 112/127] fix(wallet): validate public streaming pipeline and hosted findings --- governance/mutation-testing/targets.mjs | 3 +- .../src/storage/portable/Brc38JsonStream.ts | 18 +- .../src/storage/portable/Brc38KnexSource.ts | 6 +- .../src/storage/portable/Brc38PackedRow.ts | 8 +- .../src/storage/portable/Brc38Stream.ts | 47 +++--- .../portable/Brc39PrivateFileNode.test.ts | 19 ++- .../storage/portable/Brc39PrivateFileNode.ts | 41 +++-- .../src/storage/portable/Brc39StreamNode.ts | 18 +- .../CanonicalPortableChunks.property.test.ts | 27 +++ .../portable/CanonicalPortableChunks.ts | 12 +- .../src/storage/portable/EntryPoints.test.ts | 158 ++++++++++++++++++ scripts/mutation-testing.test.mjs | 11 +- 12 files changed, 287 insertions(+), 81 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/portable/EntryPoints.test.ts diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index fb1152a9d..fa5b99a34 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -71,7 +71,8 @@ function portableStreamMutationTargets(repositoryRoot) { '/src/storage/portable/Brc39PrivateFileNode.test.ts', '/src/storage/portable/Brc39StreamNode.test.ts', '/src/storage/portable/CanonicalPortableChunks.property.test.ts', - '/src/storage/portable/CanonicalPortableChunks.test.ts' + '/src/storage/portable/CanonicalPortableChunks.test.ts', + '/src/storage/portable/EntryPoints.test.ts' ] return Object.fromEntries( definitions.map(([id, source, property]) => [ diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.ts index b0bb42eb5..80ee48cb9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.ts @@ -47,7 +47,7 @@ const tables: readonly Table[] = Object.freeze([ 'certificateFields', 'syncStates' ]) -const isTable = (value: string): value is Table => tables.some(table => table === value) +const isTable = (value: string): value is Table => (tables as readonly string[]).includes(value) const typedArrayPrototype: object = Object.getPrototypeOf(Uint8Array.prototype) const byteLengthGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'byteLength')!.get! const bufferGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'buffer')!.get! @@ -77,11 +77,9 @@ function object(value: unknown): value is Record { } function unicode(value: string): void { for (let index = 0; index < value.length; index++) { - const unit = value.charCodeAt(index) - if (unit >= 0xd800 && unit <= 0xdbff) { - const next = value.charCodeAt(++index) - if (!(next >= 0xdc00 && next <= 0xdfff)) invalid() - } else if (unit >= 0xdc00 && unit <= 0xdfff) invalid() + const point = value.codePointAt(index)! + if (point >= 0xd800 && point <= 0xdfff) invalid() + if (point > 0xffff) index++ } } function portable(value: unknown): asserts value is Value { @@ -404,13 +402,13 @@ async function discard(input: Input | undefined, sink: Brc38JsonStagingSink, err const failures = [error] try { await input?.close() - } catch (cleanup) { - if (cleanup !== error) failures.push(cleanup) + } catch (error_) { + if (error_ !== error) failures.push(error_) } try { await sink.discard(error) - } catch (cleanup) { - failures.push(cleanup) + } catch (error_) { + failures.push(error_) } if (failures.length > 1) throw new AggregateError(failures, 'BRC-38 JSON reading and staging cleanup failed', { cause: error }) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.ts index 8a458bf8f..7e417275d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.ts @@ -327,9 +327,9 @@ export async function openBrc38KnexSource( } catch (error) { try { await release() - } catch (cleanup) { - if (cleanup === error) throw error - throw new AggregateError([error, cleanup], 'BRC-38 source opening and physical cleanup failed', { cause: error }) + } catch (error_) { + if (error_ === error) throw error + throw new AggregateError([error, error_], 'BRC-38 source opening and physical cleanup failed', { cause: error }) } throw error } diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.ts index b01e837af..014f49c24 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.ts @@ -61,11 +61,9 @@ function text(value: string, state: State): string { charge(state, 64 + 2 * value.length) for (let index = 0; index < value.length; index++) { if (index % 1024 === 0) state.signal?.throwIfAborted() - const unit = value.charCodeAt(index) - if (unit >= 0xd800 && unit <= 0xdbff) { - const next = value.charCodeAt(++index) - if (!(next >= 0xdc00 && next <= 0xdfff)) invalid() - } else if (unit >= 0xdc00 && unit <= 0xdfff) invalid() + const point = value.codePointAt(index)! + if (point >= 0xd800 && point <= 0xdfff) invalid() + if (point > 0xffff) index++ } return value } diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.ts index 3b1e72fc1..b40125b0e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.ts @@ -134,24 +134,25 @@ export async function createBrc38Stream(source: Brc38StreamSource, selected: Brc yield bytes.slice(offset, offset + maximumChunkBytes) } } + async function* tableChunks(index: number, table: Table) { + yield* literal(`${index === 0 ? '' : ','}${JSON.stringify(table)}:[`) + let first = true + for await (const row of rows(table)) { + signal?.throwIfAborted() + objectRow(row) + if (!first) yield* literal(',') + first = false + yield* canonicalPortableChunks(row, rowPolicy) + } + yield* literal(']') + } async function* body() { let failure: { error: unknown } | undefined try { yield* literal(`{"brc":38,"exportedAt":${JSON.stringify(header.exportedAt)},"formatVersion":1,"sourceStorage":`) yield* canonicalPortableChunks(header.sourceStorage, headerPolicy) yield* literal(',"tables":{') - for await (const [index, table] of tables.entries()) { - yield* literal(`${index === 0 ? '' : ','}${JSON.stringify(table)}:[`) - let first = true - for await (const row of rows(table)) { - signal?.throwIfAborted() - objectRow(row) - if (!first) yield* literal(',') - first = false - yield* canonicalPortableChunks(row, rowPolicy) - } - yield* literal(']') - } + for (const [index, table] of tables.entries()) yield* tableChunks(index, table) yield* literal('},"title":"User Wallet Data Format","user":') yield* canonicalPortableChunks(header.user, headerPolicy) yield* literal('}') @@ -163,12 +164,12 @@ export async function createBrc38Stream(source: Brc38StreamSource, selected: Brc } finally { try { await release() - } catch (cleanup) { + } catch (error_) { failure = { error: failure === undefined - ? cleanup - : new AggregateError([failure.error, cleanup], 'BRC-38 source processing and cleanup failed', { + ? error_ + : new AggregateError([failure.error, error_], 'BRC-38 source processing and cleanup failed', { cause: failure.error }) } @@ -190,8 +191,8 @@ export async function createBrc38Stream(source: Brc38StreamSource, selected: Brc const iterator = chunks() return Object.freeze({ chunks: iterator, - async validateCompleted() { - if (!completed) throw new Error('BRC-38 source stream did not complete') + validateCompleted() { + return completed ? Promise.resolve() : Promise.reject(new Error('BRC-38 source stream did not complete')) }, async close() { let failure: { error: unknown } | undefined @@ -202,11 +203,11 @@ export async function createBrc38Stream(source: Brc38StreamSource, selected: Brc } try { await release() - } catch (cleanup) { - if (failure === undefined) failure = { error: cleanup } - else if (failure.error !== cleanup) + } catch (error_) { + if (failure === undefined) failure = { error: error_ } + else if (failure.error !== error_) failure = { - error: new AggregateError([failure.error, cleanup], 'BRC-38 iterator and source cleanup failed', { + error: new AggregateError([failure.error, error_], 'BRC-38 iterator and source cleanup failed', { cause: failure.error }) } @@ -217,8 +218,8 @@ export async function createBrc38Stream(source: Brc38StreamSource, selected: Brc } catch (error) { try { await release() - } catch (cleanup) { - throw new AggregateError([error, cleanup], 'BRC-38 preparation and source cleanup failed', { cause: error }) + } catch (error_) { + throw new AggregateError([error, error_], 'BRC-38 preparation and source cleanup failed', { cause: error }) } throw error } diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts index c4d8dc3fa..58dd70250 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readdir, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' +import { mkdtemp, open, readdir, rm, stat, truncate, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { FileHandle } from 'node:fs/promises' @@ -207,12 +207,17 @@ test('private file modes and detached writes precede authenticated bounded readb await expect(stage.withAuthenticatedChunks(collect)).rejects.toThrow('has not been validated') const { directory, filename } = await stagedPath() expect((await stat(directory)).mode & 0o777).toBe(0o700) - expect((await stat(filename)).mode & 0o777).toBe(0o600) - const input = new Uint8Array(expected) - const pending = stage.appendUntrusted(input) - input.fill(0) - await pending - expect(await readFile(filename)).toEqual(expected) + const reader = await open(filename, 'r') + try { + expect((await reader.stat()).mode & 0o777).toBe(0o600) + const input = new Uint8Array(expected) + const pending = stage.appendUntrusted(input) + input.fill(0) + await pending + expect(await reader.readFile()).toEqual(expected) + } finally { + await reader.close() + } expect(validated).toBe(0) await stage.validateAuthenticated() expect(validated).toBe(1) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts index 9f6201111..678553c5c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts @@ -90,13 +90,18 @@ class PrivateFile { const write = (bytes: Uint8Array, offset: number) => writer.write(bytes, offset, bytes.length - offset, null) let offset = 0 const check = () => this.check() - async function* writes() { - while (offset < bytes.length) { - check() - yield write(bytes, offset) + const writes: AsyncIterable>> = { + [Symbol.asyncIterator]() { + return { + next() { + if (offset >= bytes.length) return Promise.resolve({ done: true as const, value: undefined }) + check() + return write(bytes, offset).then(value => ({ done: false as const, value })) + } + } } } - for await (const { bytesWritten } of writes()) { + for await (const { bytesWritten } of writes) { if (!Number.isSafeInteger(bytesWritten) || bytesWritten < 1 || bytesWritten > bytes.length - offset) throw new Error('Private quarantine write made invalid progress') this.digest.update(bytes.subarray(offset, offset + bytesWritten)) @@ -118,15 +123,21 @@ class PrivateFile { const actualDigest = createHash('sha256') const maximum = this.policy.maximumChunkBytes const check = () => this.check() - async function* reads() { - while (offset < size) { - check() - const bytes = new Uint8Array(Math.min(maximum, size - offset)) - yield reader.read(bytes, 0, bytes.length, offset) + const read = (bytes: Uint8Array) => reader.read(bytes, 0, bytes.length, offset) + const reads: AsyncIterable>> = { + [Symbol.asyncIterator]() { + return { + next() { + if (offset >= size) return Promise.resolve({ done: true as const, value: undefined }) + check() + const bytes = new Uint8Array(Math.min(maximum, size - offset)) + return read(bytes).then(value => ({ done: false as const, value })) + } + } } } async function* chunks() { - for await (const { buffer, bytesRead } of reads()) { + for await (const { buffer, bytesRead } of reads) { if (!Number.isSafeInteger(bytesRead) || bytesRead < 1 || bytesRead > buffer.length) throw new Error('Private quarantine read made invalid progress') offset += bytesRead @@ -163,8 +174,8 @@ class PrivateFile { async close => { try { await close() - } catch (cleanup) { - failure = { error: failure === undefined ? cleanup : combined(failure.error, cleanup) } + } catch (error_) { + failure = { error: failure === undefined ? error_ : combined(failure.error, error_) } } } ) @@ -272,8 +283,8 @@ export async function createBrc39NodeFileQuarantine( try { if (stage === undefined) await rm(directory, { recursive: true, force: true }) else await stage.discard() - } catch (cleanup) { - throw combined(error, cleanup) + } catch (error_) { + throw combined(error, error_) } throw error } diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts index 8fb891f1f..5128feb9d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.ts @@ -95,8 +95,8 @@ function release(session: CryptoOwnership): void { async function discard(quarantine: Pick, original: unknown): Promise { try { await quarantine.discard() - } catch (cleanup) { - throw new AggregateError([original, cleanup], 'BRC-39 processing and quarantine cleanup failed', { + } catch (error_) { + throw new AggregateError([original, error_], 'BRC-39 processing and quarantine cleanup failed', { cause: original }) } @@ -133,9 +133,9 @@ async function closeFailedSource(source: Brc39StreamPlaintext, original: unknown try { await source.close?.() return original - } catch (cleanup) { - if (cleanup === original) return original - return new AggregateError([original, cleanup], 'BRC-39 encryption and source cleanup failed', { cause: original }) + } catch (error_) { + if (error_ === original) return original + return new AggregateError([original, error_], 'BRC-39 encryption and source cleanup failed', { cause: original }) } } function* splitOutput(bytes: Uint8Array, maximumChunkBytes: number): Generator { @@ -213,8 +213,8 @@ export async function encryptBrc39StreamToQuarantine( let failure = error try { release(session) - } catch (cleanup) { - failure = new AggregateError([error, cleanup], 'BRC-39 encryption and crypto cleanup failed', { cause: error }) + } catch (error_) { + failure = new AggregateError([error, error_], 'BRC-39 encryption and crypto cleanup failed', { cause: error }) } return await discard(output, await closeFailedSource(source, failure)) } @@ -295,10 +295,10 @@ export async function decryptBrc39StreamToQuarantine( } catch (error) { try { release(session) - } catch (cleanup) { + } catch (error_) { return await discard( quarantine, - new AggregateError([error, cleanup], 'BRC-39 processing and crypto cleanup failed', { cause: error }) + new AggregateError([error, error_], 'BRC-39 processing and crypto cleanup failed', { cause: error }) ) } return await discard(quarantine, error) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts index f07093724..d6d4b217c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts @@ -55,3 +55,30 @@ test('generated JSON values preserve exact canonical bytes and per-chunk bounds' } ) }, 180000) + +test('generated UTF16 strings agree with independent UTF8 roundtrip validity and JSON bytes', () => { + fc.assert( + fc.property( + fc.array(fc.integer({ min: 0, max: 0xffff }), { maxLength: 128 }), + fc.integer({ min: 64, max: 1024 }), + (units, maximumChunkBytes) => { + const value = String.fromCharCode(...units) + const encode = () => [...canonicalPortableChunks(value, { maximumValueBytes: 4096, maximumChunkBytes })] + if (Buffer.from(value, 'utf8').toString('utf8') !== value) { + expect(encode).toThrow(TypeError) + } else { + const chunks = encode() + expect(chunks.every(chunk => chunk.byteLength > 0 && chunk.byteLength <= maximumChunkBytes)).toBe(true) + expect(Buffer.concat(chunks).toString('utf8')).toBe(JSON.stringify(value)) + } + } + ), + { + numRuns: Math.max(300, Number(process.env.FAST_CHECK_NUM_RUNS ?? 300)), + seed: Number(process.env.FAST_CHECK_SEED ?? 3242026), + ...(process.env.FAST_CHECK_PATH ? { path: process.env.FAST_CHECK_PATH } : {}), + interruptAfterTimeLimit: 150000, + markInterruptAsFailure: true + } + ) +}, 180000) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.ts b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.ts index 1d2a8a808..f5af19463 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.ts @@ -29,11 +29,9 @@ function unicode(value: string, state: State): void { charge(state, 64 + 2 * value.length) for (let index = 0; index < value.length; index++) { if (index % 1024 === 0) state.signal?.throwIfAborted() - const unit = value.charCodeAt(index) - if (unit >= 0xd800 && unit <= 0xdbff) { - const next = value.charCodeAt(++index) - if (!(next >= 0xdc00 && next <= 0xdfff)) invalid() - } else if (unit >= 0xdc00 && unit <= 0xdfff) invalid() + const point = value.codePointAt(index)! + if (point >= 0xd800 && point <= 0xdfff) invalid() + if (point > 0xffff) index++ } } function child(value: object, key: string): unknown { @@ -85,8 +83,8 @@ function* stringParts(value: string, width: number): Generator { let offset = 0 while (offset < value.length) { let end = Math.min(value.length, offset + width) - const tail = value.charCodeAt(end - 1) - if (end < value.length && tail >= 0xd800 && tail <= 0xdbff) end++ + const tail = value.codePointAt(end - 1)! + if (end < value.length && tail > 0xffff) end++ // ECMAScript escaping on a bounded window preserves JCS scalar spelling. yield JSON.stringify(value.slice(offset, end)).slice(1, -1) offset = end diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/EntryPoints.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/EntryPoints.test.ts new file mode 100644 index 000000000..9f70d5a74 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/portable/EntryPoints.test.ts @@ -0,0 +1,158 @@ +import { chmod, mkdtemp, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import * as portable from './stream' +import * as native from './node' +import { decryptBRC39, parseBRC38Json, type BRC38Tables } from './index' +import { jsonStreamFixture } from './Brc38JsonStreamFixture' + +test('public entries preserve all thirteen tables through authenticated private file staging', async () => { + const data = jsonStreamFixture() + const maximumChunkBytes = 64 + const maximumFileBytes = 65536 + // Only this small, explicitly bounded synthetic oracle is materialized. + const expected = Buffer.concat([ + ...portable.canonicalPortableChunks(data, { maximumValueBytes: maximumFileBytes, maximumChunkBytes }) + ]) + expect(parseBRC38Json(expected.toString('utf8'))).toEqual(data) + const events: string[] = [] + const visited: (keyof BRC38Tables)[] = [] + const source = await portable.createBrc38Stream( + { + sourceStorage: data.sourceStorage, + user: data.user, + async *rows(table) { + visited.push(table) + yield* data.tables[table] + }, + validateCompleted() { + expect(visited).toHaveLength(13) + expect(new Set(visited)).toEqual(new Set(Object.keys(data.tables))) + expect(parseBRC38Json(JSON.stringify(data))).toEqual(data) + events.push('source validated') + return Promise.resolve() + }, + release() { + events.push('source released') + return Promise.resolve() + } + }, + { exportedAt: data.exportedAt, maximumArchiveBytes: maximumFileBytes, maximumRowBytes: 4096, maximumChunkBytes } + ) + const encrypted: Uint8Array[] = [] + const password = 'Cafe\u0301 private fixture' + const policy = { + maximumFileBytes, + maximumChunkBytes, + maximumIterations: 7, + maximumMemoryKiB: 131072, + maximumParallelism: 1 + } + const options = { policy, maximumPasswordBytes: 1024 } + const exported = await native.encryptBrc39StreamToQuarantine( + source, + password, + { + appendUntrusted(bytes) { + expect(bytes.length).toBeLessThanOrEqual(maximumChunkBytes) + encrypted.push(bytes.slice()) + events.push(`write ${bytes.length}`) + return Promise.resolve() + }, + discard() { + encrypted.length = 0 + return Promise.resolve() + } + }, + options + ) + expect(exported.plaintextBytes).toBe(expected.length) + expect(events.filter(event => event === 'source released')).toHaveLength(1) + expect(events.slice(-3)).toEqual(['source validated', 'source released', 'write 16']) + const ciphertext = Buffer.concat(encrypted) + expect(exported.fileBytes).toBe(ciphertext.length) + expect(await decryptBRC39(ciphertext, password.normalize('NFC'))).toEqual(data) + + const parent = await mkdtemp(join(tmpdir(), 'ts-stack-entry-points-')) + let stage: native.Brc39NodeFileQuarantine | undefined + let validated = 0 + const staged: BRC38Tables = { + provenTxs: [], + provenTxReqs: [], + outputBaskets: [], + transactions: [], + commissions: [], + outputs: [], + outputTags: [], + outputTagMaps: [], + txLabels: [], + txLabelMaps: [], + certificates: [], + certificateFields: [], + syncStates: [] + } + try { + await chmod(parent, 0o700) + stage = await native.createBrc39NodeFileQuarantine( + parent, + async chunks => { + const parsed = await portable.readBrc38JsonStream( + chunks, + { + provisionalRow(table, index, row) { + expect(index).toBe(0) + expect(staged[table]).toHaveLength(0) + expect(row).toEqual(data.tables[table][index]) + ;(staged[table] as unknown[]).push(row) + return Promise.resolve() + }, + validateCompleted(header, counts) { + expect(new Set(Object.keys(counts))).toEqual(new Set(Object.keys(data.tables))) + expect(Object.values(counts)).toEqual(Array(13).fill(1)) + expect(parseBRC38Json(JSON.stringify({ ...header, tables: staged }))).toEqual(data) + return Promise.resolve() + }, + discard() { + for (const rows of Object.values(staged)) rows.length = 0 + return Promise.resolve() + } + }, + { + maximumArchiveBytes: maximumFileBytes, + maximumRowAllocationBytes: 4096, + maximumInputChunkBytes: maximumChunkBytes + } + ) + expect(parsed.inputBytes).toBe(expected.length) + validated++ + }, + { maximumFileBytes, maximumChunkBytes } + ) + await expect(stage.withAuthenticatedChunks(() => Promise.resolve(undefined))).rejects.toThrow( + 'has not been validated' + ) + async function* ciphertextChunks() { + yield* encrypted + } + const imported = await native.decryptBrc39StreamToQuarantine(ciphertextChunks(), password, stage, options) + expect(imported).toEqual(exported) + expect(validated).toBe(1) + expect(staged).toEqual(data.tables) + await stage.withAuthenticatedChunks(async chunks => { + const actual: Uint8Array[] = [] + for await (const bytes of chunks) { + expect(bytes.length).toBeLessThanOrEqual(maximumChunkBytes) + actual.push(bytes) + } + expect(Buffer.concat(actual)).toEqual(expected) + }) + await stage.discard() + expect(await readdir(parent)).toEqual([]) + } finally { + try { + await stage?.discard() + } finally { + await rm(parent, { recursive: true, force: true }) + } + } +}) diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 101ed8f3c..30a46a765 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -407,7 +407,11 @@ test('portable streaming keeps every complete source module and original behavio for (const [id, source] of modules) { const target = targets[id] assert.deepEqual(target.mutate, [`src/storage/portable/${source}.ts`]) - assert.deepEqual(target.runnerOptions.jest.config.testMatch, originalSuites) + const selectedSuites = target.runnerOptions.jest.config.testMatch + assert.deepEqual(selectedSuites.slice(0, originalSuites.length), originalSuites) + assert.deepEqual(selectedSuites.slice(originalSuites.length), [ + '/src/storage/portable/EntryPoints.test.ts' + ]) assert.deepEqual(target.additionalInputs, [ 'src/storage/portable/index.ts', 'src/storage/portable/stream.ts', @@ -418,6 +422,11 @@ test('portable streaming keeps every complete source module and original behavio `packages/wallet/wallet-toolbox/src/storage/portable/${source}.ts` ]).includes(id) ) + assert.ok( + selectAffectedMutationTargets(targets, [ + 'packages/wallet/wallet-toolbox/src/storage/portable/EntryPoints.test.ts' + ]).includes(id) + ) } }) From cb4775e21a05a454abd7c9c865d3145f5ebf0d90 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 14:00:45 -0700 Subject: [PATCH 113/127] fix(wallet): own private-file bytes before asynchronous writes --- docs/guides/wallet-sync-reliability.md | 4 +- docs/reference/package-api-migrations.md | 74 ++++++++-------- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/README.md | 4 +- .../portable/Brc39PrivateFileNode.test.ts | 87 +++++++++++++++++++ .../storage/portable/Brc39PrivateFileNode.ts | 22 +++-- 6 files changed, 148 insertions(+), 47 deletions(-) diff --git a/docs/guides/wallet-sync-reliability.md b/docs/guides/wallet-sync-reliability.md index d97f7c089..9b11a866b 100644 --- a/docs/guides/wallet-sync-reliability.md +++ b/docs/guides/wallet-sync-reliability.md @@ -26,7 +26,9 @@ remain available. The candidate adds `@bsv/wallet-toolbox/portable` for bounded BRC-38 codecs, private JSON staging and BRC-39 framing. The `/portable/node` entry adds a dedicated coherent SQL source, native encryption/decryption and private file -quarantine. The legacy materialized APIs retain their contracts. Read the +quarantine. File appends check intrinsic byte length before allocating their +owned copy and detach Node Buffers and Buffer subviews before asynchronous +writes. The legacy materialized APIs retain their contracts. Read the [portable streaming contract](wallet-data-portability.md#unpublished-bounded-streaming-entries) for explicit limits, semantic validation and cleanup requirements. These components do not advertise incremental synchronization or activate an imported diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 524e7ccb5..88d701781 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. | Public subpath | Runtime target(s) | Declaration target(s) | | ----------------- | -------------------------------------------------------------------------------- | ---------------------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 37ad601af..2e6b7c654 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open." + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 1790a12f2..cd4b0abed 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -69,7 +69,9 @@ unproved cleanup fails qualification. Other wallet shards do not start MySQL. The unpublished candidate adds optional `@bsv/wallet-toolbox/portable` and `@bsv/wallet-toolbox/portable/node` streaming entries. They require explicit resource ceilings, coherent source validation and private staging. They preserve -the existing archive format and materialized APIs. See the +the existing archive format and materialized APIs. Private-file appends check +the actual byte length before copying and own the supplied bytes before +asynchronous writes, including Node Buffers and Buffer subviews. See the [streaming contracts and remaining limits](https://bsv-blockchain.github.io/ts-stack/guides/wallet-data-portability/#unpublished-bounded-streaming-entries) before integrating them. Full issue #544 production qualification remains open. diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts index 58dd70250..1cec0476c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts @@ -245,6 +245,93 @@ test('concurrent append refuses instead of retaining queued buffers', async () = await stage.discard() }) +test.each([ + { name: 'Uint8Array', make: (bytes: Uint8Array) => new Uint8Array(bytes) }, + { name: 'Buffer', make: (bytes: Uint8Array) => Buffer.from(bytes) }, + { + name: 'Buffer subview', + make: (bytes: Uint8Array) => Buffer.concat([Buffer.from([99]), bytes, Buffer.from([100])]).subarray(1, -1) + } +])('append owns $name bytes before asynchronous writes', async ({ make }) => { + const handles = observeHandles() + const expected = new Uint8Array([11, 22, 33]) + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(Buffer.from(expected)) + }, + policy + ) + const writes = jest.spyOn(handles[0], 'write') + const input = make(expected) + const pending = stage.appendUntrusted(input) + expect(writes).toHaveBeenCalledTimes(1) + const written: unknown = writes.mock.calls[0][0] + if (!(written instanceof Uint8Array)) throw new Error('Missing owned write bytes') + expect(written.buffer).not.toBe(input.buffer) + input.fill(0) + expect(written).toEqual(expected) + await pending + await stage.validateAuthenticated() + expect(await stage.withAuthenticatedChunks(collect)).toEqual(Buffer.from(expected)) + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + +test.each([2, 3])('intrinsic %s-byte admission does not call input overrides', async length => { + const input = new Uint8Array(length).fill(17) + const getLength = jest.fn(() => 1) + const slice = jest.fn(() => input) + const iterator = jest.fn(() => [0][Symbol.iterator]()) + Object.defineProperties(input, { + length: { get: getLength }, + slice: { value: slice }, + [Symbol.iterator]: { value: iterator } + }) + const expected = length === 2 ? Buffer.from([17, 17]) : Buffer.from([9]) + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(expected) + }, + { maximumFileBytes: 3, maximumChunkBytes: 2 } + ) + if (length === 2) { + const pending = stage.appendUntrusted(input) + input.fill(0) + await pending + } else { + await expect(stage.appendUntrusted(input)).rejects.toThrow('byte policy') + await stage.appendUntrusted(new Uint8Array([9])) + } + expect(getLength).not.toHaveBeenCalled() + expect(slice).not.toHaveBeenCalled() + expect(iterator).not.toHaveBeenCalled() + await stage.validateAuthenticated() + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + +test('a detached input rejects without starting a write or closing admission', async () => { + const handles = observeHandles() + const stage = await createBrc39NodeFileQuarantine( + parent, + async chunks => { + expect(await collect(chunks)).toEqual(Buffer.from([9])) + }, + policy + ) + const input = new Uint8Array([1]) + structuredClone(input.buffer, { transfer: [input.buffer] }) + const writes = jest.spyOn(handles[0], 'write') + await expect(stage.appendUntrusted(input)).rejects.toMatchObject({ name: 'TypeError' }) + expect(writes).not.toHaveBeenCalled() + await stage.appendUntrusted(new Uint8Array([9])) + await stage.validateAuthenticated() + await stage.discard() + expect(await readdir(parent)).toEqual([]) +}) + test('chunk and cumulative file bounds refuse before additional bytes reach disk', async () => { const stage = await createBrc39NodeFileQuarantine( parent, diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts index 678553c5c..1425116fa 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.ts @@ -18,6 +18,9 @@ export interface Brc39NodeFileQuarantine extends Brc39StreamQuarantine { withAuthenticatedChunks: (consume: Consume) => Promise } type State = 'writing' | 'validating' | 'validated' | 'failed' | 'discarding' | 'discarded' +const typedArrayPrototype: object = Object.getPrototypeOf(Uint8Array.prototype) +const byteLengthGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'byteLength')!.get! +const copyBytes = Uint8Array.prototype.set function combined(original: unknown, cleanup: unknown): AggregateError { return new AggregateError([original, cleanup], 'Private quarantine operation and cleanup failed', { @@ -77,12 +80,19 @@ class PrivateFile { appendUntrusted(input: Uint8Array): Promise { if (this.state !== 'writing') return Promise.reject(new Error('Private quarantine is not writable')) if (!(input instanceof Uint8Array)) return Promise.reject(new TypeError('Private quarantine requires bytes')) - if (input.length > this.policy.maximumChunkBytes || input.length > this.policy.maximumFileBytes - this.bytes) - return Promise.reject(new SnapshotResourceLimitError('Private quarantine exceeds the selected byte policy')) - if (this.busy) return Promise.reject(new Error('Private quarantine already owns an operation')) - // Detach before asynchronous ownership. Concurrent calls refuse rather than - // retaining an unbounded queue of detached buffers. - const bytes = input.slice() + let bytes: Uint8Array + try { + const length = Reflect.apply(byteLengthGetter, input, []) as number + if (length > this.policy.maximumChunkBytes || length > this.policy.maximumFileBytes - this.bytes) + return Promise.reject(new SnapshotResourceLimitError('Private quarantine exceeds the selected byte policy')) + if (this.busy) return Promise.reject(new Error('Private quarantine already owns an operation')) + // Check intrinsic size before allocating and copy without caller methods. + // Buffer.slice shares memory. Concurrent calls refuse before copying. + bytes = new Uint8Array(length) + Reflect.apply(copyBytes, bytes, [input]) + } catch (error_) { + return Promise.reject(error_) + } return this.own(async () => { this.check() const writer = this.writer From 478c68a91a5e54323d8b76f3f9d7bf9686ba87ed Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 16:57:15 -0700 Subject: [PATCH 114/127] test(wallet): cover portable bounds and retained source lifecycle --- .../storage/portable/Brc38KnexSource.test.ts | 59 ++++++++++++ .../src/storage/portable/Brc39Frame.test.ts | 90 +++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts index 7f375ede1..8ec47efb9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts @@ -437,3 +437,62 @@ test.each([ const original = await exportBRC38(source, identity, { requireSnapshot: true }) expect(await archive(source, original.exportedAt)).toEqual(original) }) + +test.each([ + ['maximumPageRows', 1000], + ['maximumPageBytes', 16777216], + ['maximumRowAllocationBytes', 16777216], + ['maximumCertificateGroupBytes', 16777216], + ['maximumMetadataAllocationBytes', 65536] +] as const)( + '%s validates every refusal boundary before acquisition and admits its exact maximum', + async (name, maximum) => { + const cause = new Error('Synthetic acquisition boundary'), + openReadSnapshot = jest.fn(async () => { + throw cause + }), + source = { openReadSnapshot } as unknown as StorageKnex + for (const value of [0, -1, 1.5, NaN, Infinity, maximum + 1]) + await expect(openBrc38KnexSource(source, identity, { ...options, [name]: value })).rejects.toThrow(RangeError) + expect(openReadSnapshot).not.toHaveBeenCalled() + await expect(openBrc38KnexSource(source, identity, { ...options, [name]: maximum })).rejects.toBe(cause) + expect(openReadSnapshot).toHaveBeenCalledTimes(1) + } +) +test.each(['', '04' + '11'.repeat(32), '02' + '11'.repeat(31), '02' + 'gg'.repeat(32), identity + '0'])( + 'invalid compressed identity refuses before acquiring a provider: %s', + async selected => { + const openReadSnapshot = jest.fn(), + source = { openReadSnapshot } as unknown as StorageKnex + await expect(openBrc38KnexSource(source, selected, options)).rejects.toThrow(/Compressed profile identity/) + expect(openReadSnapshot).not.toHaveBeenCalled() + } +) +test('pre-aborted ownership refuses acquisition with the exact original cancellation cause', async () => { + const cause = new Error('Synthetic pre-acquisition cancellation'), + controller = new AbortController(), + openReadSnapshot = jest.fn(), + source = { openReadSnapshot } as unknown as StorageKnex + controller.abort(cause) + await expect(openBrc38KnexSource(source, identity, { ...options, signal: controller.signal })).rejects.toBe(cause) + expect(openReadSnapshot).not.toHaveBeenCalled() +}) +test('unknown table refusal keeps a real retained source available for its original table and idempotent close', async () => { + const { source } = await fixture(), + owner = await openBrc38KnexSource(source, identity, options) + try { + await expect( + owner + .rows('unknown' as Parameters[0]) + [Symbol.asyncIterator]() + .next() + ).rejects.toThrow(/Unknown BRC-38 source table/) + const iterator = owner.rows('provenTxs')[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + await expect(owner.rows('provenTxs')[Symbol.asyncIterator]().next()).rejects.toThrow(/one complete read/) + await expect(owner.validateCompleted()).rejects.toThrow(/did not complete/) + } finally { + await owner.release() + await owner.release() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts index d05ba8141..ba7835452 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts @@ -166,3 +166,93 @@ test('bounded framing agrees with SDK and native AES-GCM for a 32-byte nonce wit unauthenticated.setAuthTag(corrupt) expect(() => unauthenticated.final()).toThrow() }) + +test.each([ + ['maximumFileBytes', Number.MAX_SAFE_INTEGER], + ['maximumChunkBytes', 65536], + ['maximumIterations', 0xffffffff], + ['maximumMemoryKiB', 0xffffffff], + ['maximumParallelism', 255] +] as const)('every %s policy rejects invalid values and accepts its exact upper bound', (name, maximum) => { + for (const value of [0, -1, 1.5, NaN, Infinity, maximum + 1]) + expect(() => new Brc39StreamFrame({ ...policy, [name]: value })).toThrow(RangeError) + const frame = new Brc39StreamFrame({ ...policy, [name]: maximum }) + expect(frame.header()).toBeUndefined() +}) +test.each([ + ['iterations', 0xffffffff], + ['memoryKiB', 0xffffffff], + ['parallelism', 255] +] as const)('encoded %s preserves its exact valid boundary and refuses invalid work', (name, maximum) => { + const selected = { ...policy, maximumIterations: 0xffffffff, maximumMemoryKiB: 0xffffffff, maximumParallelism: 255 } + for (const value of [0, -1, 1.5, NaN, Infinity, maximum + 1]) + expect(() => + encodeBrc39StreamPrefix({ ...BRC39_STREAM_DEFAULT_KDF, [name]: value }, salt, nonce, selected) + ).toThrow(RangeError) + const kdf = { ...BRC39_STREAM_DEFAULT_KDF, [name]: maximum } + const prefix = encodeBrc39StreamPrefix(kdf, salt, nonce, selected) + const frame = new Brc39StreamFrame(selected) + frame.accept(prefix) + expect(frame.header()).toEqual({ ...kdf, salt, nonce }) +}) +test.each([31, 33])('export refuses salt and nonce length %i separately', length => { + expect(() => encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, new Uint8Array(length), nonce, policy)).toThrow( + TypeError + ) + expect(() => encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, salt, new Uint8Array(length), policy)).toThrow( + TypeError + ) +}) +test('export refuses non-byte salt/nonce and invalid input terminates the frame', () => { + expect(() => encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, [] as unknown as Uint8Array, nonce, policy)).toThrow( + TypeError + ) + expect(() => encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, salt, [] as unknown as Uint8Array, policy)).toThrow( + TypeError + ) + const frame = new Brc39StreamFrame(policy) + expect(() => frame.accept([] as unknown as Uint8Array)).toThrow(TypeError) + expect(() => frame.header()).toThrow(/closed/) + expect(() => frame.finish()).toThrow(/closed/) +}) +test('header stays absent at both incomplete boundaries and every salt/nonce result owns its bytes', () => { + const bytes = file(Buffer.from('owned ciphertext')), + frame = new Brc39StreamFrame(policy) + expect(frame.header()).toBeUndefined() + frame.accept(bytes.subarray(0, 32)) + expect(frame.header()).toBeUndefined() + frame.accept(bytes.subarray(32, 96)) + expect(frame.header()).toBeUndefined() + frame.accept(bytes.subarray(96, 97)) + const first = frame.header()!, + second = frame.header()! + expect(first.salt).not.toBe(second.salt) + expect(first.nonce).not.toBe(second.nonce) + first.salt.fill(0) + first.nonce.fill(0) + bytes.fill(0, 33, 97) + expect(second.salt).toEqual(new Uint8Array(32).fill(42)) + expect(second.nonce).toEqual(new Uint8Array(32).fill(42)) + expect(frame.header()).toEqual(second) +}) +test('minimum export extent includes one ciphertext byte and the complete trailing tag', () => { + expect(() => + encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, salt, nonce, { ...policy, maximumFileBytes: 113 }) + ).toThrow(SnapshotResourceLimitError) + expect( + encodeBrc39StreamPrefix(BRC39_STREAM_DEFAULT_KDF, salt, nonce, { ...policy, maximumFileBytes: 114 }) + ).toHaveLength(97) + expect(() => encodeBrc39StreamPrefix({ ...BRC39_STREAM_DEFAULT_KDF, iterations: 6 }, salt, nonce, policy)).toThrow( + /canonical/ + ) + expect(() => + encodeBrc39StreamPrefix({ ...BRC39_STREAM_DEFAULT_KDF, memoryKiB: 131071 }, salt, nonce, policy) + ).toThrow(/canonical/) +}) +test.each([22, 23, 24, 26, 27, 28, 29, 30, 31])('reserved byte %i is independently rejected', position => { + const bytes = file() + bytes[position] = 1 + const frame = new Brc39StreamFrame(policy) + expect(() => frame.accept(bytes.subarray(0, 33))).toThrow(/reserved/) + expect(() => frame.header()).toThrow(/closed/) +}) From e5f82fe0ec35ea2a630b13f0aa39654d907766a8 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 17:41:29 -0700 Subject: [PATCH 115/127] test(wallet): cover encoder boundaries and drain browser bundler --- .../client/test/sync-browser.mjs | 4 +- .../src/storage/portable/Brc38Stream.test.ts | 181 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs b/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs index d27f16e32..067f2fc12 100644 --- a/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs +++ b/packages/wallet/wallet-toolbox/client/test/sync-browser.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { createServer } from 'node:http' import { access } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { build } from 'esbuild' +import { build, stop } from 'esbuild' import puppeteer from 'puppeteer-core' const candidates = [ @@ -55,6 +55,8 @@ try { const reports = await page.evaluate(async () => await globalThis.syncBenchmark()) process.stdout.write(`${JSON.stringify({ nativeSync: reports }, null, 2)}\n`) } finally { + // This standalone runner also owns the bundler service. + await stop() if (browser !== undefined) await browser.close() await new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve()))) } diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts index 874a9ca5b..d537afcaf 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts @@ -325,3 +325,184 @@ test('generated standard rows retain source IDs, tombstones, bytes and complete } ) }, 180000) + +test.each([ + ['maximumArchiveBytes', Number.MAX_SAFE_INTEGER], + ['maximumRowBytes', 16777216], + ['maximumMetadataBytes', 65536] +] as const)('every %s boundary refuses before row access and accepts its exact maximum', async (name, maximum) => { + for (const value of [0, -1, 1.5, NaN, Infinity, maximum + 1]) { + const owner = source() + await expect(createBrc38Stream(owner, { ...options, [name]: value })).rejects.toThrow(RangeError) + expect(owner.visits).toEqual([]) + expect(owner.released).toBe(1) + } + const owner = source() + const stream = await createBrc38Stream(owner, { ...options, [name]: maximum }) + await stream.close() + expect(owner.released).toBe(1) + expect(owner.visits).toEqual([]) +}) + +test.each([0, 63, 65537, -1, 64.5, NaN, Infinity])( + 'invalid chunk bound %s releases without row access', + async value => { + const owner = source() + await expect(createBrc38Stream(owner, { ...options, maximumChunkBytes: value })).rejects.toThrow(RangeError) + expect(owner.released).toBe(1) + expect(owner.visits).toEqual([]) + } +) + +test.each([undefined, 64, 65536])( + 'default and exact chunk boundary %s retain complete portable rows', + async maximum => { + const data = document() + data.tables.outputTags[0].tag = 'bounded '.repeat(200) + const owner = source(data) + const stream = await createBrc38Stream(owner, { ...options, maximumChunkBytes: maximum }) + const parts: Uint8Array[] = [] + try { + for await (const bytes of stream.chunks) { + expect(bytes.length).toBeGreaterThan(0) + expect(bytes.length).toBeLessThanOrEqual(maximum ?? 65536) + parts.push(bytes) + } + expect(parseBRC38Json(Buffer.concat(parts).toString())).toEqual(data) + await stream.validateCompleted() + } finally { + await stream.close() + } + expect(owner.visits).toHaveLength(13) + expect(owner.validated).toBe(1) + expect(owner.released).toBe(1) + } +) + +test.each([null, undefined, [], false, 3, 'row'])( + 'non-object source row %s cannot complete provisional output', + async selected => { + const owner = source() + owner.rows = async function* () { + yield selected as unknown as BRC38Tables[keyof BRC38Tables][number] + } + const stream = await createBrc38Stream(owner, options) + await expect(collect(stream.chunks)).rejects.toThrow(/portable object rows/) + await expect(stream.validateCompleted()).rejects.toThrow(/did not complete/) + await stream.close() + expect(owner.validated).toBe(0) + expect(owner.released).toBe(1) + } +) + +test('pre-aborted preparation releases the owned source and retains its exact cancellation cause', async () => { + const owner = source() + const controller = new AbortController() + const cause = new Error('Synthetic cancellation before stream preparation') + controller.abort(cause) + await expect(createBrc38Stream(owner, { ...options, signal: controller.signal })).rejects.toBe(cause) + expect(owner.visits).toEqual([]) + expect(owner.released).toBe(1) +}) + +test('preparation and physical cleanup failures preserve both independent exact causes', async () => { + const owner = source() + const controller = new AbortController() + const cause = new Error('Synthetic cancelled preparation') + const cleanup = new Error('Synthetic preparation cleanup failure') + controller.abort(cause) + owner.release = async () => { + owner.released++ + throw cleanup + } + await expect(createBrc38Stream(owner, { ...options, signal: controller.signal })).rejects.toMatchObject({ + cause, + errors: [cause, cleanup] + }) + expect(owner.visits).toEqual([]) + expect(owner.released).toBe(1) +}) + +test('physical release failure after semantic validation prevents stream completion', async () => { + const owner = source() + const cleanup = new Error('Synthetic release failure after source validation') + owner.release = async () => { + owner.released++ + throw cleanup + } + const stream = await createBrc38Stream(owner, options) + await expect(collect(stream.chunks)).rejects.toBe(cleanup) + await expect(stream.validateCompleted()).rejects.toThrow(/did not complete/) + await expect(stream.close()).rejects.toBe(cleanup) + expect(owner.validated).toBe(1) + expect(owner.released).toBe(1) +}) + +test('cancellation during source validation prevents completion after the final provisional bytes', async () => { + const owner = source() + const controller = new AbortController() + const cause = new Error('Synthetic cancellation during semantic source validation') + const validate = owner.validateCompleted + owner.validateCompleted = async () => { + await validate() + controller.abort(cause) + } + const stream = await createBrc38Stream(owner, { ...options, signal: controller.signal }) + await expect(collect(stream.chunks)).rejects.toBe(cause) + await expect(stream.validateCompleted()).rejects.toThrow(/did not complete/) + await stream.close() + expect(owner.validated).toBe(1) + expect(owner.released).toBe(1) +}) + +test('closing a paused row stream retains iterator-return and source-release failure identities', async () => { + const data = document() + const owner = source(data) + const returning = new Error('Synthetic row iterator return failure') + const cleanup = new Error('Synthetic physical stream release failure') + let started = false + owner.rows = () => ({ + [Symbol.asyncIterator]() { + return { + async next() { + started = true + return { done: false as const, value: data.tables.certificateFields[0] } + }, + async return() { + throw returning + } + } + } + }) + owner.release = async () => { + owner.released++ + throw cleanup + } + const stream = await createBrc38Stream(owner, options) + const iterator = stream.chunks[Symbol.asyncIterator]() + while (!started) await iterator.next() + await expect(stream.close()).rejects.toMatchObject({ + cause: expect.objectContaining({ cause: returning, errors: [returning, cleanup] }), + errors: [expect.objectContaining({ cause: returning, errors: [returning, cleanup] }), cleanup] + }) + await expect(stream.validateCompleted()).rejects.toThrow(/did not complete/) + expect(owner.validated).toBe(0) + expect(owner.released).toBe(1) +}) + +test.each(['中'.repeat(23000), '\u0001'.repeat(11500)])( + 'expanded canonical metadata bytes refuse independently of detached allocation before row access: %#', + async storageName => { + const data = document() + data.sourceStorage.storageName = storageName + for (const table of Object.keys(data.tables) as Array) data.tables[table] = [] + expect(Buffer.byteLength(JSON.stringify(data))).toBeGreaterThan(65536) + const owner = source(data) + await expect(createBrc38Stream(owner, { ...options, maximumMetadataBytes: 65536 })).rejects.toThrow( + 'BRC-38 metadata exceeds the selected byte policy' + ) + expect(owner.visits).toEqual([]) + expect(owner.validated).toBe(0) + expect(owner.released).toBe(1) + } +) From 9b4aa5330e3fbf9dfd617ca1effe0a7ab88a34bc Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 18:35:07 -0700 Subject: [PATCH 116/127] docs(governance): review dated evidence for PR 569 --- docs/reference/test-quality-governance.md | 14 +- governance/test-quality/policy.json | 76 +++--- governance/test-quality/review-2026-10-05.md | 240 +++++++++++++++++++ 3 files changed, 285 insertions(+), 45 deletions(-) create mode 100644 governance/test-quality/review-2026-10-05.md diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 825f2e0e6..3ba8a90d9 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -2,9 +2,9 @@ id: test-quality-governance title: 'Test Quality and Skip Governance' kind: reference -version: '1.1.0' -last_updated: '2026-07-26' -last_verified: '2026-08-26' +version: '1.1.1' +last_updated: '2026-10-05' +last_verified: '2026-10-05' review_cadence_days: 30 status: stable tags: [reference, governance, quality, security, testing] @@ -70,8 +70,8 @@ pnpm --filter @bsv/sdk test:resource ## Property-based security tests -Required CI uses `fast-check` to generate and shrink unexpected inputs across -25 packages and the stack's highest-risk trust boundaries: +Required CI uses `fast-check` to generate and shrink unexpected inputs at the +registered packages' highest-risk trust boundaries: - binary and text codecs: SDK Base58Check, DID base64url/multibase/SD-JWT, Bitcoin script numbers, asset outpoints, wallet action packs, and native BDK @@ -102,8 +102,8 @@ Every property suite and package declaration is registered under `propertyTesting` in the policy. The governance check rejects a removed suite, an unregistered `*.property.test.ts`, a missing package command, an undeclared library/version, a missing trust-boundary/invariant description, or a run budget -below 300 generated cases. It also inventories all 33 package manifests: each -must either own a registered property suite or have a dated, owned exclusion +below 300 generated cases. It also inventories all package manifests discovered +under `packages/`: each must either own a registered property suite or have a dated, owned exclusion that explains why the package is only an adapter, composition layer, example, or platform harness. This prevents both silent coverage gaps and low-value properties added solely to increase a package count. diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 0920c7d02..5d7285d48 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-09-27", + "lastReviewed": "2026-10-05", "ownerDefinitions": ["ts-stack-maintainers"], "propertyTesting": { "library": "fast-check", @@ -11,7 +11,7 @@ "workflow": ".github/workflows/property-tests.yml", "replayEnvironment": ["FAST_CHECK_NUM_RUNS", "FAST_CHECK_SEED", "FAST_CHECK_PATH"], "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04", + "reviewBy": "2026-10-12", "manifests": [ "packages/sdk/package.json", "packages/wallet/wallet-toolbox/package.json", @@ -475,7 +475,7 @@ "Trust, status and privacy policy are never inferred from wrapper claims." ], "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "packages/helpers/did/tests/brc52-envelope.property.test.ts", @@ -489,7 +489,7 @@ "Trust, status and privacy policy are never inferred from wrapper claims." ], "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "packages/helpers/did/tests/brc52-status.property.test.ts", @@ -503,7 +503,7 @@ "Trust, status and privacy policy are never inferred from wrapper claims." ], "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "packages/helpers/did/tests/brc52-disclosure.property.test.ts", @@ -517,7 +517,7 @@ "Trust, status and privacy policy are never inferred from wrapper claims." ], "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts", @@ -773,35 +773,35 @@ "kind": "adapter-or-composition", "rationale": "This convenience facade composes wallet, DID, Message Box, overlay, and token clients without owning an independent wire codec or authorization parser.", "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "manifest": "packages/messaging/ts-paymail/docs/examples/package.json", "kind": "example-or-platform", "rationale": "This private documentation example has no reusable runtime boundary; the Paymail address and protocol parser is governed in the parent package.", "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "manifest": "packages/wallet/wallet-toolbox-examples/package.json", "kind": "example-or-platform", "rationale": "This private examples workspace demonstrates governed wallet-toolbox APIs and does not define an independent parser or security decision.", "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "manifest": "packages/wallet/wallet-toolbox/client/package.json", "kind": "adapter-or-composition", "rationale": "This client surface exposes wallet-toolbox behavior whose authenticated binary framing and storage invariants are governed in the parent package.", "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "manifest": "packages/wallet/wallet-toolbox/mobile/package.json", "kind": "example-or-platform", "rationale": "This React Native platform harness exercises the governed wallet and relay packages but does not own an independent arbitrary-input boundary.", "owner": "ts-stack-maintainers", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" } ] }, @@ -820,7 +820,7 @@ "cadence": "Run the relevant suite before a release that changes its boundary and during an operator investigation; do not batch-run all operator suites.", "invocation": "pnpm --filter @bsv/wallet-toolbox test:manual -- ", "lastRunEvidence": null, - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "id": "wallet-public-live", @@ -835,8 +835,8 @@ "cleanup": "Unsubscribe the SSE listener and clear its timeout.", "cadence": "Weekly and before a release that changes the Go Chaintracks client.", "invocation": "pnpm --filter @bsv/wallet-toolbox test:live -- ", - "lastRunEvidence": "governance/test-quality/review-2026-09-27.md", - "reviewBy": "2026-10-04" + "lastRunEvidence": "governance/test-quality/review-2026-10-05.md", + "reviewBy": "2026-10-12" }, { "id": "overlay-public-live", @@ -850,8 +850,8 @@ "cleanup": "No persistent resource is created.", "cadence": "Weekly and before a release that changes Overlay provider integration.", "invocation": "pnpm --filter @bsv/overlay-express test:live", - "lastRunEvidence": "governance/test-quality/review-2026-09-27.md", - "reviewBy": "2026-10-04" + "lastRunEvidence": "governance/test-quality/review-2026-10-05.md", + "reviewBy": "2026-10-12" }, { "id": "sdk-resource", @@ -866,7 +866,7 @@ "cadence": "Before an SDK release that changes AES-GCM length handling and on a monthly resource-test run.", "invocation": "pnpm --filter @bsv/sdk test:resource", "lastRunEvidence": null, - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" } ], "manualRules": [ @@ -903,7 +903,7 @@ "owner": "ts-stack-maintainers", "reason": "A valid case requires deterministic certificate and PushDrop signatures from subject and certifier wallets; the current helpers only construct invalid boundary fixtures.", "removeWhen": "Add deterministic certificate-construction helpers and assert admission plus decrypted public attributes.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts", @@ -912,7 +912,7 @@ "owner": "ts-stack-maintainers", "reason": "The IndexedDB wallet scenario passes alone but leaves a handle open when run with the complete Jest suite.", "removeWhen": "Identify and close the leaked handle, give the scenario a behavioral name, and enable it in the required suite.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" } ], "conformanceSkipGroups": [ @@ -922,7 +922,7 @@ "expectedSkips": 4, "owner": "ts-stack-maintainers", "removeWhen": "Delete the four obsolete signature vectors after cross-language consumers confirm the replacement deterministic vectors.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/regressions/beef-v2-txid-panic.json", @@ -930,7 +930,7 @@ "expectedSkips": 1, "owner": "ts-stack-maintainers", "removeWhen": "The Go SDK handles empty BEEF consistently and the vector can become required.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/regressions/bip276-hex-decode.json", @@ -938,7 +938,7 @@ "expectedSkips": 3, "owner": "ts-stack-maintainers", "removeWhen": "The TypeScript stack exposes an equivalent BIP276 contract or the vectors are formally marked unsupported for TypeScript.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/regressions/privatekey-modular-reduction.json", @@ -946,7 +946,7 @@ "expectedSkips": 2, "owner": "ts-stack-maintainers", "removeWhen": "The Go SDK validates and reduces private-key scalars consistently with the TypeScript SDK.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/sdk/crypto/ecies.json", @@ -954,7 +954,7 @@ "expectedSkips": 1, "owner": "ts-stack-maintainers", "removeWhen": "The ECIES dispatcher validates the wrong-key error shape directly or the obsolete duplicate vector is removed.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/sdk/scripts/evaluation.json", @@ -962,7 +962,7 @@ "expectedSkips": 37, "owner": "ts-stack-maintainers", "removeWhen": "Each node-context, consensus, or flag-sensitive script case is implemented or formally classified as outside the standalone evaluator contract.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/sync/brc40-user-state.json", @@ -970,7 +970,7 @@ "expectedSkips": 24, "owner": "ts-stack-maintainers", "removeWhen": "A seeded producer/consumer sync harness validates BRC-40 state, merge, and offset behavior.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/abortaction.json", @@ -978,7 +978,7 @@ "expectedSkips": 6, "owner": "ts-stack-maintainers", "removeWhen": "The wallet harness creates deterministic in-flight actions before abort vectors execute.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/acquirecertificate.json", @@ -986,7 +986,7 @@ "expectedSkips": 5, "owner": "ts-stack-maintainers", "removeWhen": "Vectors use valid deterministic signatures and an ephemeral issuance endpoint.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/createaction.json", @@ -994,7 +994,7 @@ "expectedSkips": 90, "owner": "ts-stack-maintainers", "removeWhen": "A deterministic funded mock-chain wallet supplies change UTXOs for createAction vectors.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/discoverbyattributes.json", @@ -1002,7 +1002,7 @@ "expectedSkips": 1, "owner": "ts-stack-maintainers", "removeWhen": "A deterministic overlay lookup fixture returns matching certifier attributes.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/discoverbyidentitykey.json", @@ -1010,7 +1010,7 @@ "expectedSkips": 2, "owner": "ts-stack-maintainers", "removeWhen": "A deterministic overlay lookup fixture returns certifier identity records.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/internalizeaction.json", @@ -1018,7 +1018,7 @@ "expectedSkips": 8, "owner": "ts-stack-maintainers", "removeWhen": "Placeholder byte arrays are replaced with valid deterministic BEEF fixtures.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/listactions.json", @@ -1026,7 +1026,7 @@ "expectedSkips": 1, "owner": "ts-stack-maintainers", "removeWhen": "The wallet harness seeds the expected action before the list request.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/listcertificates.json", @@ -1034,7 +1034,7 @@ "expectedSkips": 1, "owner": "ts-stack-maintainers", "removeWhen": "The wallet harness seeds the expected certificate before the list request.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/provecertificate.json", @@ -1042,7 +1042,7 @@ "expectedSkips": 7, "owner": "ts-stack-maintainers", "removeWhen": "The wallet harness seeds deterministic certificates and proof keys.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/relinquishcertificate.json", @@ -1050,7 +1050,7 @@ "expectedSkips": 4, "owner": "ts-stack-maintainers", "removeWhen": "The wallet harness seeds deterministic certificates before relinquishment.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/relinquishoutput.json", @@ -1058,7 +1058,7 @@ "expectedSkips": 6, "owner": "ts-stack-maintainers", "removeWhen": "The wallet harness seeds deterministic outputs before relinquishment.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" }, { "path": "conformance/vectors/wallet/brc100/signaction.json", @@ -1066,7 +1066,7 @@ "expectedSkips": 8, "owner": "ts-stack-maintainers", "removeWhen": "The wallet harness creates and retains deterministic in-flight actions for signing.", - "reviewBy": "2026-10-04" + "reviewBy": "2026-10-12" } ] } diff --git a/governance/test-quality/review-2026-10-05.md b/governance/test-quality/review-2026-10-05.md new file mode 100644 index 000000000..115fdb839 --- /dev/null +++ b/governance/test-quality/review-2026-10-05.md @@ -0,0 +1,240 @@ +# Test-governance review — 2026-10-05 + +Owner: `ts-stack-maintainers` +Next review: **2026-10-12** + +This shared record preserves separate source-specific reviews for PRs #674 and #569. Each inventory and runtime observation belongs only to its recorded source. The authenticated `ty-everett` maintainer writer division is [recorded on BotBoard](https://github.com/bsv-blockchain/ts-stack/discussions/772#discussioncomment-18751211). No peer inventory or score qualifies the other branch. + +## Source-specific review: PR #674 + +The reviewed source base is `aa3133f98667703b72a4af3a4c8479d61be0c65a`. + +UTC rollover exposed 172 expired records on this branch. Thirty-five dates are +stored directly; other suite records inherit the property-policy date. The +real-date evaluator reports only expiry findings. A prospective seven-day +renewal reports zero findings with the original source inventory and controls. +The renewal changes those existing dates, `lastReviewed`, and the two public-live +evidence references. It adds no skip, exclusion, exception, suppression, owner, +threshold or accepted finding and changes no expiry-validation behavior. + +### Registered boundaries and existing exclusions + +The registry contains 141 property suites across 32 package manifests, five +composition/example exclusions, and 141 canonical mutation targets. The full +execution matrix contains 388 rows. Suite source and manifest digests were +catalogued with their existing trust boundaries and invariants; the review +receipt SHA-256 is +`ede586615e064f87c5b616d4aea76bcd105318d9d3c4b035ada5cdd168f3be17`. +Required discovery still contains 1,368 files and exactly two governed direct +skips. The minimum 300 generated cases, scheduled 5,000 cases, replay inputs, +complete-source target unions, score requirements and zero-error gates remain. +Cataloguing the registry is not a fresh runtime or full mutation qualification. + +The five exclusions retain their existing rationale. The simple facade composes +the governed wallet, DID, messaging, overlay and token packages; the Paymail and +wallet examples remain private examples. The wallet client delegates its framing +and storage boundaries to the governed parent package, and the mobile harness +exercises those parent APIs. This review adds no independently reusable parser +or authorization decision to an excluded surface and removes no parent suite. + +The earlier 5,000-case campaigns linked in the September 27 review remain +historical observations on their recorded sources. This review does not claim a +new 5,000-case campaign. The October 4 source's SDK and application CI observations +remain attached to that exact source; new-head checks are still required after +the maintenance commit. + +### Manual and public integration evidence + +All 30 wallet manual/live paths still match the exact inventory. Twenty-nine +source hashes match the October 1 review. The remaining +`internalizeAction.a.man.test.ts` adds only the guarded PostgreSQL wallet-copy +setup; removing that line reproduces the previously reviewed bytes. Its current +SHA-256 is +`9dffe9d4d652993d53abd1c704b668df6648075a3c5e681a11ab81fb22c08e26`. +Every original test and assertion remains, and its existing `funded-state` +classification still applies. The inventory retains 14 funded-state, one local +artifact, nine public-network, one read-only remote and five remote-state suites. +Its separate October 8 review deadline is unchanged. + +The original credential-free wallet public command passes three tests in one +suite, including the mainnet header subscription. The original Overlay public +command passes six tests in two module projects, exercising mainnet and +TerraTestNet headers and a mined proof. These are fresh October 5 observations on +the reviewed source, using the existing public endpoints and no private +credentials. Their original 240-second supervisors remain; elapsed times are +7.459 and 7.306 seconds including package builds. Authored inputs remain fixed +and both owned process groups are physically absent. + +No funded, remote-state or other operator suite was executed. The SDK's separately +invoked large AES-GCM resource suite remains unexecuted by this review; its memory, +selection and cleanup requirements remain. Retaining these classifications is +not evidence of service readiness, restored funds or resource-suite success. + +### Required-test and conformance gaps + +Identity admission still lacks the deterministic valid certificate/PushDrop +fixture; the complete IndexedDB wallet scenario still has its documented +full-suite handle issue. Their exact two skip records and objective removal +conditions remain. [QA issue #400](https://github.com/bsv-blockchain/ts-stack/issues/400) +is still open. Neither a passing isolated test nor a new maintenance date closes +these gaps. + +Fresh read-only structural conformance passes all 77 vector files and 6,700 +vectors. The first report-writing invocation passed its assertions but changed a +tracked generated report, so its input-integrity proof is unqualified. That +evidence is retained, only its own generated change was restored, and the +documented complete `--validate-only` command independently passes with fixed +inputs and physical drain. + +Fresh TypeScript conformance passes 6,491 tests across two suites with the same +211 governed skips. All 4,424 captured compiled inputs stay fixed and the owned +process group is absent. The 19 groups retain their exact classifications, +counts and removal conditions: upstream/runtime parity, missing stateful or +funded harnesses, missing overlay/certificate fixtures, and obsolete or +language-specific vectors remain limitations. These results do not establish +cross-language parity for the skipped vectors. + +### Qualification and next review + +The full mutation campaign +[37241159066](https://github.com/bsv-blockchain/ts-stack/actions/runs/37241159066) +is terminal cancelled because this necessary source renewal requires a new head. +Its 180 passing execution rows are partial evidence, not full qualification. +Earlier deadline and native-worker/invalid-mutant failures also remain +unqualified; this review neither attributes their cause nor waives their gates. +All 141 targets and 388 rows must qualify together on the resulting immutable +source with the independent final raw-report recheck. Checkpoint two remains +incomplete, and no peer, historical or diagnostic score can qualify it. + +This renewal is bounded to seven days. Before the next review, reconcile the +existing required-test, conformance, operator/resource and complete-campaign gaps +with their concrete removal or qualification requirements. Do not repeatedly +move dates without reviewing new evidence and recording what remains unresolved. + +## Source-specific review: PR #569 + +Reviewed source: `e5f82fe0ec35ea2a630b13f0aa39654d907766a8`. + +The original real-date governance command reports 88 expired records at UTC rollover. This review renews existing dated records for seven days after checking the current inventory, recorded boundaries and current evidence. It adds no skip, exclusion, classification, waiver, accepted finding or threshold change. Expiry enforcement remains unchanged. This review does not establish complete #400 or #544 acceptance. + +### Current evidence and its limits + +- The actual-date evaluator reports 1,130 required test files, two governed direct skips, 57 property suites across 31 property packages, five composition/platform exclusions, 57 mutation targets, 32 classified manual/live files, 30 exact Wallet Toolbox dispositions, and 211 conformance skips across 19 files. Before renewal, its only 88 errors are the October 4 review dates; all other inventory/control/rationale checks pass. The resulting-source original governance and complete gates remain required. +- The complete portable ordinary union passes 406 tests in 14 suites, retaining all original 346 cases and the original property and deadline controls. Six root/security checks and all eight Node/browser/mobile/build/pack/platform steps pass. These component observations do not qualify the complete program or mutation campaign. +- Exact-head hosted conformance [37248493422](https://github.com/bsv-blockchain/ts-stack/actions/runs/37248493422) passes 6,497 assertions, with exactly 211 existing governed skips. The six independent BRC-118 vectors pass. Missing cross-language and seeded-state parity remains incomplete. +- Credential-free original selected live suites pass on this source: Wallet Chaintracks 3/3 and Overlay provider 3/3. Original public mainnet/TerraTestNet header, SSE and mined-proof assertions remain intact. Both owned process groups terminate; 3,408 authored inputs remain unchanged. No funded or remote-state operation is performed. +- Full mutation [37234415102](https://github.com/bsv-blockchain/ts-stack/actions/runs/37234415102) at prior source `cb4775e21` ends unqualified with failures and cancellations. New-head pure-frame diagnostic [37248544628](https://github.com/bsv-blockchain/ts-stack/actions/runs/37248544628) fails at expired-governance preparation before mutant execution. Neither run is borrowed as a pass. Original score and zero-uncovered/invalid/unexecuted requirements remain. + +### Registered property boundaries + +Every current suite retains the recorded critical/high boundary, invariants, `MIN_PROPERTY_RUNS = 300`, global fast-check configuration and all three replay environment variables. Scheduled strength remains 5,000. This is a registration/source review, not a claim that all 57 suites received a fresh complete runtime qualification. Current exact-source hashes bind the reviewed registration inputs. + +| Suite | Boundary | SHA-256 | +| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| `packages/sdk/src/primitives/__tests/utils.property.test.ts` | SDK binary-to-text codecs used by keys, addresses, and protocol payloads | `46ee5885071ab5d8ee3f1a2664dae84881f3c270c4ac5dd2256b23478efa8537` | +| `packages/sdk/src/wallet/__tests/BRC100ByteEncoding.property.test.ts` | BRC-100 wallet byte recovery and arbitrary application JSON serialization | `6b7e93d4995760fc7deca259c7064edc673ccf80274388d049477c64f1dbe5c1` | +| `packages/sdk/src/auth/clients/__tests__/AuthFetch.property.test.ts` | SDK BRC-103 authenticated HTTP response parsing and pending-request lifecycle | `7cc0576019a546899531157cb76ed434026e2c977e7c760a3127c3f51b9cdf41` | +| `packages/wallet/wallet-toolbox/src/utility/__tests/actionBatchPack.property.test.ts` | Wallet authenticated action-batch binary framing | `0f9d642b9d687f9e381926e4248b6b8c1b05875e474f784cfff17e84e4bb415a` | +| `packages/overlays/topics/src/mandala/__tests/types.property.test.ts` | Overlay Mandala BRC-162 envelope, admin-details schema and issuer authorization | `23354013cceb883225ab92f79f5e84d7703c098cadb82bd98796e882385fcccd` | +| `packages/helpers/did/tests/codec.property.test.ts` | DID, multibase, base64url, JSON, and SD-JWT compact encodings | `1ab422e153eb35771e92114d833007be9dd759e30b32fa6ef1186bec4f11368e` | +| `packages/helpers/ts-templates/src/__tests/mandala-encoding.property.test.ts` | Bitcoin script numbers, BRC-162 push canonicality and the strict CBOR subset | `60ec6e350a43c3082b7546f475af05f7971814f4334b02a8e291380a2b803904` | +| `packages/messaging/ts-paymail/src/__tests/paymailAddress.property.test.ts` | Public Paymail alias and DNS-name parser | `7929182b79b2f652074a00a8a2889722edab9ad2940ae4add4630cf365a4fb4d` | +| `packages/network/ts-p2p/test/messages.property.test.ts` | Untrusted Teranode GossipSub UTF-8, JSON, and base64 envelopes | `acba0479574bb8ccac5350b2327a975fc4ffa89ff74e5cdc9459dedf92bff353` | +| `packages/middleware/auth/src/__tests__/core.property.test.ts` | Authentication signature canonicalization, body binding, and freshness validation | `210adfbc26cf111347d5b9e548ae16d927449e41a417e3828106a6b6f8752fa7` | +| `packages/overlays/overlay-express/src/__tests/ReorgStream.property.test.ts` | Public chain-reorg SSE framing and height/hash normalization | `828e8996183d3e3ceea57d3287b46e0de5324b98a91ab532aad023fd9b802c7e` | +| `packages/messaging/message-box-client/src/__tests/host.property.test.ts` | Untrusted overlay-advertised Message Box network destinations | `8084d8cb2b2a2b7719eb313cc712e8fdf7b91eefa2934a1fedf23b4a7ba2581f` | +| `packages/messaging/authsocket/src/__tests__/eventPayload.property.test.ts` | Server-side BRC-103 Socket.IO authentication and authenticated event payload ingress | `8201a6b7d5691c29ff632dbbc63b759057aeea4d79d7ad6fa67d3a3011c36685` | +| `packages/messaging/authsocket-client/src/__tests__/eventPayload.property.test.ts` | Client-side BRC-103 Socket.IO authentication and authenticated event payload ingress | `de5fb6496c600ecfc4a833bb2ceb5cd7aa943b2ada465148e91c99dc22cda507` | +| `packages/wallet/ts-wallet-relay/tests/pairingUri.property.test.ts` | Mobile/deep-link wallet pairing URI trust bootstrap | `f9cc5e795b21a61be50a6702914f39e664143c231374e57174cf1fdf9c871c03` | +| `packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts` | BRC-87 names and BRC-101 public service advertisements | `8d93911ef01b35f7a588dc098dd315fff4272fd392166caf6f87bd39c34aa744` | +| `packages/overlays/overlay/src/__tests/BASM.property.test.ts` | Overlay BASM/TAC integrity hashing and untrusted log serialization | `cb21538232d962ba67bf530d508b7e06c8fb4a4d437820edffc7b3dadfb9616d` | +| `packages/verifast/src/__tests/BdkBatch.property.test.ts` | Native BDK batch memory framing, offsets, flags, and result decoding | `b241fb914e3e16eb4ae8a89bf6e2bcf8c7919bbe1a7b9996f8bdf995e8024a25` | +| `packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts` | BTMS wallet metadata, message bodies, labels, and key-derivation instructions | `1fe346bbde674ee8e01f73ddbbe93f90b8b4665ef7474a37996d91ef58576a2f` | +| `packages/helpers/air-gap/tests/airGapCodec.property.test.ts` | Camera-supplied optical wire parts decoded into application payload bytes | `82778e41fa3aa4f0382fd4ee671989904057f331b884304399008c8fc1f17c9f` | +| `packages/helpers/amountinator/tests/amountFormat.property.test.ts` | Wallet-facing currency conversion, decimal formatting, and safe-integer display | `a5972248bbfa9549c28fc25292ffa4088c272462b9c74db11112a39c3665b968` | +| `packages/helpers/fund-wallet/src/cli.property.test.ts` | Operator-supplied wallet keys, funding amounts, networks, and storage endpoints | `0db766b990d9df4b66a138eaa5b881bde20871e1910f8eb1383a34393b914516` | +| `packages/middleware/402-pay/src/server.property.test.ts` | Public BRC-121 payment challenge identity and price headers | `9aacfa5031fc4e0c2ea72ec61688a1598487479ebe624fc9a45d410425c8cf6c` | +| `packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts` | Authenticated Express request and response canonical byte serialization | `0d9c9b2e298276164132eec7b4bec52c5b32f9e50da748b2c1133a7c659c10d8` | +| `packages/middleware/payment-express-middleware/src/__tests/PaymentReplayStore.property.test.ts` | Public payment replay admission and bounded in-memory capacity | `f51937ce7b2a3fbcf7ee5c91cc4b7465c8036a77d449486d6c0e49350c78ac5c` | +| `packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptEncoding.property.test.ts` | Wallet transaction script classification and arbitrary OP_RETURN binary payload framing | `139a4e8ee374b803314b8d6a3509d1ade44789d63a8edf46ebd580c48d925c4d` | +| `packages/helpers/create-bsv-app/src/config/__tests__/validate.property.test.ts` | Untrusted project-scaffolder configuration and filesystem-relative output destinations | `5d05a93e4617b98ce3e0ff8831ba8cb999cce7038b56f50543be543b6fe3af59` | +| `packages/overlays/gasp-core/src/__tests/GASP.property.test.ts` | Public Graph Aware Sync Protocol request, response, pagination, and UTXO-set reconciliation | `aa37df9e18f17d14061b79f83e2e91534356453a1e85438befe5e93b10dc1bef` | +| `packages/overlays/btms-backend/src/topic-managers/__tests/BTMSTopicManager.property.test.ts` | Untrusted BTMS overlay token amount and asset identifier fields | `ff8720608ffdbc97664d5ab966c486fd36184105fae36934bfbd4aa5e878326f` | +| `packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.property.test.ts` | BTMS wallet authorization sessions, request shapes, and Bitcoin varint framing | `b6b2bc6155f9c2274485622044b90fae688e1a210666afffe8ade2a8a5a7a6d9` | +| `packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts` | Wallet-derived secret scalars applied to untrusted caller-supplied secp256k1 points | `1df50177de11664fa865d25448b1a8de1599b2bf2902c47b16fb3903c61527a0` | +| `packages/network/chirp/test/codec.property.test.ts` | Untrusted CHIRP binary manifests and arbitrary source bytes crossing the content-addressed storage boundary | `7528999c5b5ab3157f3b2d6b4201ce57d532f8b8eccb519ca37e04cfad8fc8ee` | +| `packages/content/lch/test/cbor.property.test.ts` | Untrusted BRC-170 deterministic CBOR objects crossing signature, identifier, payment, policy, and persistence boundaries | `3b983d201be595c50b276b4fb0e9f3959b859867fc37a5f44f5d62180e4e055a` | +| `packages/helpers/did/tests/brc202.property.test.ts` | Immutable identity-key DID resolution and strict profile options | `6dc87fc14a4455f62cc66661134a00f623f60f0e3e543acd2974ea9595708357` | +| `packages/helpers/did/tests/brc52-envelope.property.test.ts` | Original BRC52 signed byte parsing, derived signature and exact projected graph | `fc51135f61f1e46aea5dee65ef2387f1de971fb310d703740592f7c2ce5d2032` | +| `packages/helpers/did/tests/brc52-status.property.test.ts` | Locally selected outpoint status evidence, freshness and privacy | `7f3860be1a4637e128abc5f862d2aa04cfad2ec0ca3fb54c3aa6853bdf7dfb93` | +| `packages/helpers/did/tests/brc52-disclosure.property.test.ts` | Consent, exact authenticated payload, recipient binding, replay and field decryption | `6b047ea31499be668b0b257889de6951316ed5537ce66e84396827f119e81004` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/RetainedReadSnapshot.property.test.ts` | Wallet retained database-view admission, expiry, cancellation and physical cleanup ownership, profile-bound keyset cursors and bounded packed payload admission | `fb0fad580c66fc349642e2c81d354de3b9eee602efec711ad66355910147a314` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournal.property.test.ts` | Wallet exact journal revisions, bounded metadata pages and bootstrap, full-table observers, source binding and owned crash-resumable generation lifecycle | `8d1898ebf119b0d4b7f25d7e05a6456ebcf20c11c5cde64d58df9e1b98ff8283` | +| `packages/wallet/wallet-toolbox/src/storage/sync/SyncPageBudget.property.test.ts` | Wallet adaptive sync page work and payload forecasts, caller limits and current-table transition isolation | `26f007fc07537855e62d2618d5b6282fb988479fd637686b503d5bc7212474b8` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSync.property.test.ts` | Wallet durable profile-bound snapshot merges, normalized ID mappings, acknowledged cursors and primary-generation fencing | `cec9635a290dc2a6b6ee922a08fc5bed023c525b3a4d092e1d6498adb304e336` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncDestination.property.test.ts` | Wallet durable snapshot destination binding, atomic checkpoint admission and primary-generation fencing | `3206153c2e39ec3ecffd941c2c49af8512887397733672e597efe86093b5f9d0` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/SnapshotSyncRows.property.test.ts` | Wallet bounded snapshot row detachment and profile/source-scoped normalized parent and child ID mapping | `6b45fb40243c53fd34421561c9d1a598bc6af7b2ce616ca5e9c5d594c7dae7eb` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts` | Wallet coherent SQL snapshot capture and staging, profile closure, immutable receipts, quotas and cleanup accounting | `3950cf7f7cfbb1d85668007f3096e5d12c524e12b2cc16f86959bc6c2fccf9e5` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts` | Wallet authenticated snapshot receipt directories, original binding bytes, complete table hash chains and page inclusion | `c5a79f2753a26eb914edcdff44a411b0604ef7b4263fc05fb13d3719d6c5554b` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts` | Wallet remote snapshot creation request identity, durable receipt publication, bounded reservation, exact source ownership, backend guards and physical cleanup recovery | `a683c448b5564326045b366d80f6ee6d421e5635991fcd9da4e52acb4e86ab81` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts` | Wallet authenticated snapshot HTTP negotiation, exact protocol fields, profile binding, immutable client receipts and bounded transport lifecycle | `c0880f7738938128393e2e122cda85057b753fbcf5b4267b043aa0712bf440f1` | +| `packages/wallet/wallet-toolbox/src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts` | Wallet immutable remote snapshot row decoding, fixed archive cursors, bounded leases, exact admission recovery and pending cleanup | `97dbbd1fd402658432ba540c2fdacf1529a1bf03e52ede3f747b2002dd2667ec` | +| `packages/wallet/wallet-toolbox/src/storage/portable/mysqlReadSnapshot.property.test.ts` | Wallet callback-owned coherent transaction classification, exact proof/checkpoint consistent reads and preservation of writable authority locks | `8968c16fc8f1005614024c6dfcf84c11a62ddd9c15934b9e08e3a42ff8873fdb` | +| `packages/wallet/wallet-toolbox/src/storage/portable/CanonicalPortableChunks.property.test.ts` | Allocation-bounded canonical RFC8785 portable serialization and detached source ownership | `8781395e53b7e41b77ffb19113f9b3550dbb487c5316ea4007d6abb49d5f3dd8` | +| `packages/wallet/wallet-toolbox/src/storage/portable/Brc38PackedRow.test.ts` | Bounded packed-row binary, date and historical JSON projection preserving exact legacy portable semantics | `60d829023d5c0e3ab38fa882b8d9db65ac3ea9bc418fbbf43617662a20875c36` | +| `packages/wallet/wallet-toolbox/src/storage/portable/Brc38Stream.test.ts` | Complete thirteen-table canonical stream framing, original source validation and awaited cleanup before completion | `bb043abb8614447fcf6e1af4489f623e24c29845b123a359118432aec76713e9` | +| `packages/wallet/wallet-toolbox/src/storage/portable/Brc38KnexSource.test.ts` | Coherent retained SQLite/MySQL thirteen-table keyset sources, bounded payload admission and profile closure | `0ea2cf149d7c23fe6185deb24533acef37882671ed780a9767a1c6884566e854` | +| `packages/wallet/wallet-toolbox/src/storage/portable/Brc38JsonStream.property.test.ts` | Bounded strict UTF8 and complete BRC38 document framing into private staging with semantic completion and cleanup | `c3b853b3134d41ca28d56ffab505a18f9dc5fcd13f009914d98143f1b3880911` | +| `packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.property.test.ts` | Bounded standard WDAT envelope framing and explicit KDF/file admission preserving complete ciphertext and tag | `bdc515bc47cba1de594fa188aa5df3751b3ea515e2f2a951222646affe1e8e2b` | +| `packages/wallet/wallet-toolbox/src/storage/portable/Brc39StreamNode.test.ts` | Node BRC39 native AEAD, canonical Argon2id/NFC, owned key/password cleanup and private staging backpressure | `85bf99afefdf58b2bb06206a806869ab7fc322b30d3b9430b06227cad4df42e1` | +| `packages/wallet/wallet-toolbox/src/storage/portable/Brc39PrivateFileNode.test.ts` | Node private authenticated-file quarantine, bounded serial I/O, complete verification and physical cleanup | `d455ed5b1c40cdddf6a86e81c2037e71d3e53cae1b6e650bdf6e3e6de43f9178` | + +### Retained composition and platform exclusions + +Each of the five records retains its owner, kind, rationale, governed parent boundary and manifest. Client/mobile native platform gates pass; examples and composition facades do not acquire a separate arbitrary-input contract through this renewal. + +| Manifest | Retained rationale | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/helpers/simple/package.json` | This convenience facade composes wallet, DID, Message Box, overlay, and token clients without owning an independent wire codec or authorization parser. | +| `packages/messaging/ts-paymail/docs/examples/package.json` | This private documentation example has no reusable runtime boundary; the Paymail address and protocol parser is governed in the parent package. | +| `packages/wallet/wallet-toolbox-examples/package.json` | This private examples workspace demonstrates governed wallet-toolbox APIs and does not define an independent parser or security decision. | +| `packages/wallet/wallet-toolbox/client/package.json` | This client surface exposes wallet-toolbox behavior whose authenticated binary framing and storage invariants are governed in the parent package. | +| `packages/wallet/wallet-toolbox/mobile/package.json` | This React Native platform harness exercises the governed wallet and relay packages but does not own an independent arbitrary-input boundary. | + +### Manual and required-test gaps + +The current inventory has 29 literal source hashes matching the prior review and one independently reconciled guarded PostgreSQL context addition, with the exact current hash recorded in the #674 review above. No funded or remote-state suite was executed for #569. + +The [October 1 wallet inventory review](./wallet-manual-review-2026-10-01.md) remains current through October 8 and retains all 30 exact dispositions, original assertion oracles and funded/remote-state classifications. Operator suites still require exact selection and their documented setup; this record grants no funding, remote mutation, deployment or physical-device authorization. The SDK resource test is not freshly executed and still requires an isolated run with at least 2 GiB free memory. Its null last-run evidence remains null. + +The two required skips retain their exact classifications, reasons and removal conditions. Identity admission still needs a deterministic valid certificate/PushDrop fixture. The IndexedDB full-suite handle leak remains a known defect; this review neither reproduces nor claims to fix it. + +### Retained conformance gaps + +The 19 groups below still total 211 skips. Their classifications and removal conditions are literal current registry values. None is treated as an executed successful vector. + +| Vector file | Classification | Skips | Removal condition | +| ------------------------------------------------------------------- | ----------------- | ----: | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `conformance/vectors/messaging/brc31/authrite-signature.json` | superseded-vector | 4 | Delete the four obsolete signature vectors after cross-language consumers confirm the replacement deterministic vectors. | +| `conformance/vectors/regressions/beef-v2-txid-panic.json` | upstream-parity | 1 | The Go SDK handles empty BEEF consistently and the vector can become required. | +| `conformance/vectors/regressions/bip276-hex-decode.json` | language-specific | 3 | The TypeScript stack exposes an equivalent BIP276 contract or the vectors are formally marked unsupported for TypeScript. | +| `conformance/vectors/regressions/privatekey-modular-reduction.json` | upstream-parity | 2 | The Go SDK validates and reduces private-key scalars consistently with the TypeScript SDK. | +| `conformance/vectors/sdk/crypto/ecies.json` | runner-gap | 1 | The ECIES dispatcher validates the wrong-key error shape directly or the obsolete duplicate vector is removed. | +| `conformance/vectors/sdk/scripts/evaluation.json` | runtime-parity | 37 | Each node-context, consensus, or flag-sensitive script case is implemented or formally classified as outside the standalone evaluator contract. | +| `conformance/vectors/sync/brc40-user-state.json` | stateful-harness | 24 | A seeded producer/consumer sync harness validates BRC-40 state, merge, and offset behavior. | +| `conformance/vectors/wallet/brc100/abortaction.json` | stateful-harness | 6 | The wallet harness creates deterministic in-flight actions before abort vectors execute. | +| `conformance/vectors/wallet/brc100/acquirecertificate.json` | fixture-gap | 5 | Vectors use valid deterministic signatures and an ephemeral issuance endpoint. | +| `conformance/vectors/wallet/brc100/createaction.json` | funded-harness | 90 | A deterministic funded mock-chain wallet supplies change UTXOs for createAction vectors. | +| `conformance/vectors/wallet/brc100/discoverbyattributes.json` | overlay-harness | 1 | A deterministic overlay lookup fixture returns matching certifier attributes. | +| `conformance/vectors/wallet/brc100/discoverbyidentitykey.json` | overlay-harness | 2 | A deterministic overlay lookup fixture returns certifier identity records. | +| `conformance/vectors/wallet/brc100/internalizeaction.json` | fixture-gap | 8 | Placeholder byte arrays are replaced with valid deterministic BEEF fixtures. | +| `conformance/vectors/wallet/brc100/listactions.json` | stateful-harness | 1 | The wallet harness seeds the expected action before the list request. | +| `conformance/vectors/wallet/brc100/listcertificates.json` | stateful-harness | 1 | The wallet harness seeds the expected certificate before the list request. | +| `conformance/vectors/wallet/brc100/provecertificate.json` | stateful-harness | 7 | The wallet harness seeds deterministic certificates and proof keys. | +| `conformance/vectors/wallet/brc100/relinquishcertificate.json` | stateful-harness | 4 | The wallet harness seeds deterministic certificates before relinquishment. | +| `conformance/vectors/wallet/brc100/relinquishoutput.json` | stateful-harness | 6 | The wallet harness seeds deterministic outputs before relinquishment. | +| `conformance/vectors/wallet/brc100/signaction.json` | stateful-harness | 8 | The wallet harness creates and retains deterministic in-flight actions for signing. | + +Before the next bounded review, continue resolving these concrete gaps and complete current source-specific runtime/mutation/security/review evidence. Do not renew dates automatically or use this record to hide failures. + +The original exact-head Sonar zero-findings command passes at `e5f82fe0ec35ea2a630b13f0aa39654d907766a8`, including zero blocking findings, accepted/false-positive classifications and unreviewed hotspots. CodeQL and container contracts pass on the same source. CI and the pure-frame diagnostic remain failed at expired review preparation. These pre-renewal observations cannot qualify the resulting commit, full mutation campaign or all eleven #544 acceptance rows. From eff2e16ff4833106a54edd02d71502676a086893 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 19:09:06 -0700 Subject: [PATCH 117/127] fix(wallet): detach streamed BRC-39 Buffer ciphertext --- docs/guides/wallet-data-portability.md | 18 ++++- docs/reference/package-api-migrations.md | 74 +++++++++---------- governance/package-release-notes.json | 4 +- .../src/storage/portable/Brc39Frame.test.ts | 35 +++++++++ .../src/storage/portable/Brc39Frame.ts | 2 +- 5 files changed, 89 insertions(+), 44 deletions(-) diff --git a/docs/guides/wallet-data-portability.md b/docs/guides/wallet-data-portability.md index 25f42ec09..4790b0766 100644 --- a/docs/guides/wallet-data-portability.md +++ b/docs/guides/wallet-data-portability.md @@ -3,8 +3,8 @@ id: wallet-data-portability title: 'BRC-38/39 Wallet Data Portability' kind: guide version: '1.0.0' -last_updated: '2026-10-04' -last_verified: '2026-10-04' +last_updated: '2026-10-05' +last_verified: '2026-10-05' review_cadence_days: 30 status: stable tags: [wallet, backup, interoperability, brc38, brc39] @@ -82,6 +82,8 @@ Encryption emits the existing WDAT envelope with canonical Argon2id defaults (7 Decryption writes only into isolated quarantine. GCM authentication must precede strict UTF-8 and complete BRC-38 semantic validation. `createBrc39NodeFileQuarantine` uses a caller-owned trusted parent, private 0700/0600 files, bounded serial reads/writes, fsync, content verification and explicit awaited `discard()`. Its `withAuthenticatedChunks` callback is available after validation and closes its reader even on early return. Always discard after the host operation settles. It does not import, activate a profile or provide a durable recovery transaction. `readBrc38JsonStream` likewise accepts authenticated plaintext and awaits one private staging callback at a time; its host validator must check every staged row, unique key, relation, identity, network and original provenance before a result can be used. +The BRC-39 frame returns owned ciphertext buffers, including when input comes from Node Buffers or Buffer subviews. Callers may reuse their input chunks after `accept()` returns; changing emitted bytes does not alter the frame's retained tag or later output. + These entry points bound each component's admitted work and buffers. They do not establish native allocator/RSS/IPC bounds, hard database/WAL/directory quotas, durable occupied-target restore, replicated remote export destinations, or physical mobile qualification. Those remain mandatory in the full #544 program. No pending intermediate API is a released production guarantee. A selected host Argon2id backend must return a fresh owned 32-byte key buffer @@ -243,7 +245,12 @@ identity/network, obtain the user's confirmation of the target and mode, then invoke import. Do not trust an archive's identity as the selected profile. ```ts -import { decryptBRC39, importBRC38, type BRC38WalletData, type StorageProvider } from '@bsv/wallet-toolbox' +import { + decryptBRC39, + importBRC38, + type BRC38WalletData, + type StorageProvider +} from '@bsv/wallet-toolbox' export async function previewWalletDataFile( bytes: Uint8Array, @@ -262,7 +269,10 @@ export async function previewWalletDataFile( } // Call only after profile/target confirmation. Do not mutate the preview. -export async function restoreConfirmedWalletData(emptyTarget: StorageProvider, document: BRC38WalletData) { +export async function restoreConfirmedWalletData( + emptyTarget: StorageProvider, + document: BRC38WalletData +) { return await importBRC38(emptyTarget, document, { mode: 'restore' }) } ``` diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 88d701781..98c4b70ef 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. +- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. +- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. | Public subpath | Runtime target(s) | Declaration target(s) | | ----------------- | -------------------------------------------------------------------------------- | ---------------------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 2e6b7c654..5827d3ac8 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write." + "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract.", + "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts index ba7835452..b968a9c10 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts @@ -256,3 +256,38 @@ test.each([22, 23, 24, 26, 27, 28, 29, 30, 31])('reserved byte %i is independent expect(() => frame.accept(bytes.subarray(0, 33))).toThrow(/reserved/) expect(() => frame.header()).toThrow(/closed/) }) + +test.each(['bytes', 'buffer', 'buffer-view'] as const)( + 'ciphertext emitted from %s owns its bytes across caller and output mutation', + kind => { + const ciphertext = Buffer.from('independent ciphertext crossing the retained trailing-tag window') + const original = file(ciphertext) + const backing = Buffer.concat([Buffer.alloc(7, 88), original, Buffer.alloc(7, 88)]) + const inputs = { + bytes: original.slice(), + buffer: Buffer.from(original), + 'buffer-view': backing.subarray(7, backing.length - 7) + } + const input = inputs[kind] + const frame = new Brc39StreamFrame(policy) + const boundary = 97 + 23 + const first = frame.accept(input.subarray(0, boundary)) + expect(Buffer.concat(first)).toEqual(ciphertext.subarray(0, 7)) + input.fill(0, 97, boundary) + expect(Buffer.concat(first)).toEqual(ciphertext.subarray(0, 7)) + for (const chunk of first) chunk.fill(77) + const second = frame.accept(input.subarray(boundary)) + expect(Buffer.concat(second)).toEqual(ciphertext.subarray(7)) + input.fill(0, boundary) + expect(Buffer.concat(second)).toEqual(ciphertext.subarray(7)) + for (const chunk of second) chunk.fill(66) + const end = frame.finish() + expect(end.tag).toEqual(new Uint8Array(16).fill(99)) + expect(end.fileBytes).toBe(original.length) + expect(end.ciphertextBytes).toBe(ciphertext.length) + if (kind === 'buffer-view') { + expect(backing.subarray(0, 7)).toEqual(Buffer.alloc(7, 88)) + expect(backing.subarray(backing.length - 7)).toEqual(Buffer.alloc(7, 88)) + } + } +) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.ts index e45e35432..c0cd57d5d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.ts @@ -142,7 +142,7 @@ export class Brc39StreamFrame { const fromInput = emit - fromTail const result: Uint8Array[] = [] if (fromTail !== 0) result.push(this.tail.slice(0, fromTail)) - if (fromInput !== 0) result.push(input.slice(0, fromInput)) + if (fromInput !== 0) result.push(new Uint8Array(input.subarray(0, fromInput))) this.tail.copyWithin(0, fromTail, this.tailUsed) this.tailUsed -= fromTail this.tail.set(input.subarray(fromInput), this.tailUsed) From bc7d10c90a467708100cddbb3a81d5b68d307749 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 4 Oct 2026 19:34:44 -0700 Subject: [PATCH 118/127] test(wallet): cover BRC-39 envelope admission boundaries --- .../src/storage/portable/Brc39Frame.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts index b968a9c10..0aa370e37 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/Brc39Frame.test.ts @@ -291,3 +291,80 @@ test.each(['bytes', 'buffer', 'buffer-view'] as const)( } } ) + +test.each([1, 32, 255])('the exact minimum complete envelope is admitted with %i-byte salt and nonce', length => { + const bytes = file(Buffer.from([73]), length, length) + const frame = new Brc39StreamFrame({ ...policy, maximumFileBytes: bytes.length, maximumChunkBytes: 1 }) + const output: Uint8Array[] = [] + expect(frame.accept(new Uint8Array())).toEqual([]) + for (const byte of bytes) output.push(...frame.accept(Uint8Array.of(byte))) + expect(frame.accept(new Uint8Array())).toEqual([]) + expect(frame.header()?.salt).toEqual(new Uint8Array(length).fill(42)) + expect(frame.header()?.nonce).toEqual(new Uint8Array(length).fill(42)) + expect(Buffer.concat(output)).toEqual(Buffer.from([73])) + expect(frame.finish()).toEqual({ + tag: new Uint8Array(16).fill(99), + fileBytes: 33 + length * 2 + 1 + 16, + ciphertextBytes: 1 + }) + expect(() => frame.accept(new Uint8Array())).toThrow(/closed/) + expect(() => frame.finish()).toThrow(/closed/) + const insufficient = new Brc39StreamFrame({ ...policy, maximumFileBytes: bytes.length - 1 }) + expect(() => insufficient.accept(bytes.subarray(0, 33))).toThrow(SnapshotResourceLimitError) + expect(() => insufficient.header()).toThrow(/closed/) +}) + +test('a complete prefix and trailing tag require at least one ciphertext byte', () => { + const bytes = file(Buffer.alloc(0)) + const frame = new Brc39StreamFrame({ ...policy, maximumFileBytes: bytes.length + 1 }) + expect(frame.accept(bytes)).toEqual([]) + expect(frame.header()).toBeDefined() + expect(() => frame.finish()).toThrow(/Truncated or empty/) + expect(() => frame.accept(Uint8Array.of(1))).toThrow(/closed/) + expect(() => frame.header()).toThrow(/closed/) +}) + +test('import rejects a key length one byte below the fixed 32-byte contract', () => { + const bytes = file() + bytes[20] = 31 + const frame = new Brc39StreamFrame(policy) + expect(() => frame.accept(bytes.subarray(0, 33))).toThrow(/key length/) + expect(() => frame.finish()).toThrow(/closed/) +}) + +test.each([65536, 65537])('the actual %i-byte input obeys the 64 KiB chunk ceiling', length => { + const ciphertext = Buffer.alloc(length - 97 - 16, 73) + const bytes = file(ciphertext) + expect(bytes).toHaveLength(length) + const frame = new Brc39StreamFrame({ ...policy, maximumFileBytes: bytes.length }) + if (length > 65536) { + expect(() => frame.accept(bytes)).toThrow(SnapshotResourceLimitError) + expect(() => frame.header()).toThrow(/closed/) + expect(() => frame.accept(bytes.subarray(0, 1))).toThrow(/closed/) + } else { + const output = frame.accept(bytes) + expect(Buffer.concat(output)).toEqual(ciphertext) + expect(output.every(chunk => chunk.length <= 65536)).toBe(true) + expect(frame.finish()).toEqual({ + tag: new Uint8Array(16).fill(99), + fileBytes: bytes.length, + ciphertextBytes: ciphertext.length + }) + } +}) + +test('the accumulated file ceiling admits its last byte and closes on the next nonempty chunk', () => { + const bytes = file(Buffer.from('independent accumulated-file oracle')) + const frame = new Brc39StreamFrame({ ...policy, maximumFileBytes: bytes.length, maximumChunkBytes: 97 }) + const output = [...frame.accept(bytes.subarray(0, 97))] + expect(frame.accept(new Uint8Array())).toEqual([]) + output.push(...frame.accept(bytes.subarray(97, bytes.length - 1))) + expect(frame.accept(new Uint8Array())).toEqual([]) + output.push(...frame.accept(bytes.subarray(bytes.length - 1))) + expect(Buffer.concat(output)).toEqual(Buffer.from('independent accumulated-file oracle')) + expect(frame.accept(new Uint8Array())).toEqual([]) + expect(() => frame.accept(Uint8Array.of(0))).toThrow(SnapshotResourceLimitError) + expect(() => frame.accept(new Uint8Array())).toThrow(/closed/) + expect(() => frame.header()).toThrow(/closed/) + expect(() => frame.finish()).toThrow(/closed/) +}) From 24b2b4f3d592bbcd28801b0ab8a0d928d553021c Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 5 Oct 2026 14:12:59 -0700 Subject: [PATCH 119/127] perf(wallet): batch fresh SQLite metadata without retained state --- docs/reference/package-api-migrations.md | 74 +++--- governance/mutation-testing/targets.mjs | 3 + governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 6 + packages/wallet/wallet-toolbox/README.md | 5 + .../schema/snapshotSqliteDefinitions.test.ts | 87 +++++++ .../storage/schema/snapshotSqliteIdentity.ts | 28 ++- ...snapshotSqliteIdentityObservations.test.ts | 230 ++++++++++++++++++ .../snapshotSqliteIdentityObservations.ts | 48 ++++ .../schema/snapshotSqliteIndexGeneration.ts | 72 ++++-- ...apshotJournalSqliteObserverColumns.test.ts | 176 ++++++++++++++ .../journal/SnapshotJournalSqliteObservers.ts | 35 ++- scripts/mutation-partitions.mjs | 1 + 13 files changed, 709 insertions(+), 60 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteDefinitions.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObserverColumns.test.ts diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 98c4b70ef..7045632aa 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. | -| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.2` | `2.6.3` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `1.9.1` | `2.0.0` | major | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. | +| `@bsv/wallet-toolbox-client` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `@bsv/wallet-toolbox-mobile` | `2.14.4` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. -- Migration: Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. +- Release note: Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. +- Migration: These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. | Public subpath | Runtime target(s) | Declaration target(s) | | ----------------- | -------------------------------------------------------------------------------- | ---------------------------------------- | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index fa5b99a34..ad198bf70 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -586,6 +586,7 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'src/storage/schema/snapshotGlobalIndexTriggers.ts', 'src/storage/schema/snapshotSqliteIdentity.ts', + 'src/storage/schema/snapshotSqliteIdentityObservations.ts', 'src/storage/schema/snapshotSqliteMembership.ts', 'src/storage/schema/snapshotSqliteIndexGeneration.ts', 'src/storage/schema/snapshotSqliteIndexBootstrap.ts', @@ -633,6 +634,8 @@ export function buildMutationTargets(repositoryRoot) { 'jest.config.cjs', [ '/src/storage/snapshot/*.test.ts', + '/src/storage/schema/snapshotSqliteIdentityObservations.test.ts', + '/src/storage/schema/snapshotSqliteDefinitions.test.ts', '/src/storage/snapshot/journal/SnapshotJournalCapture*.test.ts', '/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts', '/src/storage/snapshot/journal/SnapshotJournalMaintenance*.test.ts', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 5827d3ac8..a060ff862 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract.", - "migration": "Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews." + "summary": "Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract.", + "migration": "These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 9cfdfe6a3..0147e33ed 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,12 @@ attention to changes that materially alter behavior or extend functionality. ## 2.15.0 candidate — bounded synchronization and canonical proof recovery +- Read fresh bounded SQLite identity and journal-observer metadata once per + operation, group rows in one pass, and validate installed definitions in pages + of at most sixteen. Preserve exact metadata, DDL and validation/error order; + no schema cache, API, wire or database migration is required. Full #544 + production qualification remains open. + - Preserve queued primary reselection: a request to select A during a pending switch from A to B waits for that switch and then selects A, including after an earlier failure. Check no-op selections under the existing ownership diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index cd4b0abed..d25144ce3 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -194,6 +194,11 @@ triggers from views even when their names coincide; foreign readers of auxiliary data refuse migration. Preserve partial state for recovery; ordinary downgrade refuses, while explicit `dropAllData()` retains its destructive contract. See the [SQLite generation migration contract](https://bsv-blockchain.github.io/ts-stack/guides/wallet-sync-reliability/#sqlite-conflict-safe-index-generation-unpublished-candidate). +SQLite identity and journal-observer validation read fresh bounded metadata pages +for each operation. Metadata is grouped in one pass, and installed schema +definitions are checked in pages of at most sixteen. Exact metadata, DDL, +validation order and error identities are retained; there is no cross-operation +schema cache. The existing schema and BRC-38/39 formats need no migration. The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteDefinitions.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteDefinitions.test.ts new file mode 100644 index 000000000..fa724d503 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteDefinitions.test.ts @@ -0,0 +1,87 @@ +import { fixture as sqliteFixture } from '../../../test/utils/snapshotSqliteFixtures' +import type { Knex } from 'knex' +import { metadata, readPlan, validateInstalled, installGeneration, type Plan } from './snapshotSqliteIndexGeneration' +import { runInSeries } from '../../utility/runInSeries' + +describe('actual native exact generation-definition reads', () => { + let fixture: { k: Knex } + let plan: Plan + beforeAll(async () => { + fixture = { k: await sqliteFixture('BINARY', false, false) } + await installGeneration(fixture.k) + }) + beforeEach(async () => { + plan = await readPlan(fixture.k) + }) + afterAll(async () => { + await fixture?.k.destroy() + }) + test('reads every exact type/name in pages of at most sixteen without writes or authority caches', async () => { + const queries: Array<{ sql: string; bindings: unknown[] }> = [] + const observe = (query: { sql: string; bindings: unknown[] }) => queries.push(query) + fixture.k.on('query', observe) + try { + await validateInstalled(fixture.k, plan) + } finally { + fixture.k.removeListener('query', observe) + } + const expected = [...plan.ddl, ...plan.triggers].map(sql => { + const match = /^CREATE (TABLE|INDEX|TRIGGER) ("[^"]+"|\w+)/.exec(sql)! + return [match[1].toLowerCase(), match[2].replaceAll('"', '')] + }) + const pages = queries.filter(query => query.sql.startsWith('select `type`, `name`, `sql` from `sqlite_master`')) + expect(expected.length).toBeGreaterThan(16) + expect(pages).toHaveLength(Math.ceil(expected.length / 16)) + expect(pages.every(query => query.bindings.length > 0 && query.bindings.length <= 32)).toBe(true) + expect(pages.flatMap(query => query.bindings)).toEqual(expected.flat()) + expect(queries.every(query => /^(select|PRAGMA|EXPLAIN)\b/i.test(query.sql))).toBe(true) + const later = { ...plan, ddl: [...plan.ddl] } + later.ddl[0] += '\n' + await expect(validateInstalled(fixture.k, later)).rejects.toThrow('Rebuild schema definition mismatch: ') + }) + test.each(['TABLE', 'INDEX', 'TRIGGER'])('still compares the entire %s SQL text', async type => { + const changed = { ...plan, ddl: [...plan.ddl], triggers: [...plan.triggers] } + const list = type === 'TRIGGER' ? changed.triggers : changed.ddl + const index = list.findIndex(sql => sql.startsWith('CREATE ' + type + ' ')) + expect(index).toBeGreaterThanOrEqual(0) + list[index] += '\n' + await expect(validateInstalled(fixture.k, changed)).rejects.toThrow('Rebuild schema definition mismatch: ') + }) + test('pairs the object type with its exact name and rejects a missing definition', async () => { + const changed = { ...plan, ddl: [...plan.ddl] } + changed.ddl[0] = changed.ddl[0].replace('CREATE TABLE ', 'CREATE INDEX ') + await expect(validateInstalled(fixture.k, changed)).rejects.toThrow('Rebuild schema definition mismatch: ') + changed.ddl[0] = plan.ddl[0].replace('snapshot_profile_keys_v2', 'fixture_missing_definition') + await expect(validateInstalled(fixture.k, changed)).rejects.toThrow( + 'Rebuild schema definition mismatch: fixture_missing_definition' + ) + }) + test('preserves the first mismatch before a later malformed definition and reports malformed plans', async () => { + const changed = { ...plan, ddl: [...plan.ddl] } + changed.ddl[0] += '\n' + changed.ddl[1] = 'invalid generated fixture definition' + await expect(validateInstalled(fixture.k, changed)).rejects.toThrow( + 'Rebuild schema definition mismatch: snapshot_profile_keys_v2' + ) + changed.ddl[0] = plan.ddl[0] + await expect(validateInstalled(fixture.k, changed)).rejects.toThrow('Invalid generated schema definition') + }) + test('retains duplicate expected definitions and refuses a changed source plan', async () => { + await expect(validateInstalled(fixture.k, { ...plan, ddl: [...plan.ddl, plan.ddl[0]] })).resolves.toBeUndefined() + await expect(validateInstalled(fixture.k, { ...plan, source: plan.source + ' ' })).rejects.toThrow( + 'Rebuild source schema changed' + ) + }) + test('checks the actual source-binding metadata after the complete schema read', async () => { + const original = await fixture.k(metadata).where('id', 0).first() + await runInSeries([{ complete: 2 }, { retireTable: -1 }, { source: plan.source + ' ' }], async change => { + try { + await fixture.k(metadata).where('id', 0).update(change) + await expect(validateInstalled(fixture.k, plan)).rejects.toThrow('Rebuild source binding mismatch') + } finally { + await fixture.k(metadata).where('id', 0).update(original) + } + }) + await expect(validateInstalled(fixture.k, plan)).resolves.toBeUndefined() + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentity.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentity.ts index f5a81600d..2a2f678d2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentity.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentity.ts @@ -1,6 +1,7 @@ import { runInSeries } from '../../utility/runInSeries' import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' import type { Knex } from 'knex' +import { readSqliteIdentityObservations, type SqliteIdentityObservation } from './snapshotSqliteIdentityObservations' // Metadata-bound conflict witnesses are maintained by the additive SQLite generation. export interface SourceIdentity { table: string @@ -25,7 +26,30 @@ export interface IdentityDefinition { } const quote = (name: string): string => '"' + name.replaceAll('"', '""') + '"' export async function readIdentity(k: Knex, source: SourceIdentity): Promise { - const columns: Column[] = await k.raw('PRAGMA table_xinfo(??)', [source.table]) + return await readIdentityCurrent(k, source) +} + +/** Fresh source metadata is scoped to this one construction. No caller can + * supply observations or reuse them as generation/commit authority. */ +export async function readIdentities(k: Knex, sources: SourceIdentity[]): Promise { + const observed = await readSqliteIdentityObservations( + k, + sources.map(source => source.table) + ) + const identities: IdentityDefinition[] = [] + await runInSeries(sources.entries(), async ([index, source]) => { + const selected = observed?.[index] + identities.push(await readIdentityCurrent(k, source, selected?.table === source.table ? selected : undefined)) + }) + return identities +} + +async function readIdentityCurrent( + k: Knex, + source: SourceIdentity, + observed?: SqliteIdentityObservation +): Promise { + const columns: Column[] = observed?.columns ?? (await k.raw('PRAGMA table_xinfo(??)', [source.table])) const primary = columns.filter(column => column.pk !== 0) if (primary.length !== 1 || primary[0].name !== source.key || primary[0].type.toLowerCase() !== 'integer') throw new WERR_INVALID_OPERATION('Unsupported numeric identity') @@ -33,7 +57,7 @@ export async function readIdentity(k: Knex, source: SourceIdentity): Promise = await k.raw('PRAGMA index_list(??)', [source.table]) + }> = observed?.indexes ?? (await k.raw('PRAGMA index_list(??)', [source.table])) const unique: Part[][] = [] await runInSeries( indexes.filter(index => index.unique !== 0), diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.test.ts new file mode 100644 index 000000000..2ec0e10d6 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.test.ts @@ -0,0 +1,230 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { readIdentity, readIdentities, identityDDL, type SourceIdentity } from './snapshotSqliteIdentity' +import { readSqliteIdentityObservations } from './snapshotSqliteIdentityObservations' + +const sources: SourceIdentity[] = [ + { table: 'identity_a', key: 'id', owner: 'owner' }, + { table: 'identity_b', key: 'id', owner: 'owner' } +] +async function fixture(filename = ':memory:'): Promise { + const k = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + try { + if (filename !== ':memory:') await k.raw('PRAGMA journal_mode=WAL') + await k.raw( + 'CREATE TABLE identity_a(id INTEGER PRIMARY KEY,owner INTEGER NOT NULL,code VARCHAR(10) COLLATE NOCASE UNIQUE)' + ) + await k.raw( + 'CREATE TABLE identity_b(id INTEGER PRIMARY KEY,owner INTEGER NOT NULL,code VARCHAR(10) COLLATE RTRIM UNIQUE)' + ) + return k + } catch (error) { + await k.destroy() + throw error + } +} +// The independent oracle uses the retained original individual fresh PRAGMAs +// on this same healthy fixture. It does not qualify another driver/platform. +async function original(k: Knex, values = sources) { + const result = [] + for (const source of values) result.push(await readIdentity(k, source)) + return result +} + +test('two fresh bounded metadata statements retain every original identity and DDL byte', async () => { + const k = await fixture() + try { + const queries: string[] = [] + const observer = (query: { sql: string }) => queries.push(query.sql) + k.on('query', observer) + const actual = await readIdentities(k, sources) + k.off('query', observer) + expect(queries.filter(sql => sql.includes('pragma_table_xinfo'))).toHaveLength(1) + expect(queries.filter(sql => sql.includes('pragma_index_list'))).toHaveLength(1) + expect(actual).toEqual(await original(k)) + expect(actual.map(identityDDL)).toEqual((await original(k)).map(identityDDL)) + expect(actual[0].columns[0]).toHaveProperty('cid') + expect(actual[0].columns[0]).toHaveProperty('dflt_value') + expect(actual[0].columns[0]).not.toHaveProperty('sourceOrdinal') + } finally { + await k.destroy() + } +}) + +test('valid unique-index changes and rollback are read afresh', async () => { + const k = await fixture() + try { + const before = await readIdentities(k, sources) + await expect( + k.transaction(async t => { + await t.raw('CREATE UNIQUE INDEX extra_identity ON identity_a(owner,code)') + expect(await readIdentities(t, sources)).toEqual(await original(t)) + expect(await readIdentities(t, sources)).not.toEqual(before) + throw new Error('owned ordinary rollback') + }) + ).rejects.toThrow('owned ordinary rollback') + expect(await readIdentities(k, sources)).toEqual(before) + await k.raw('CREATE UNIQUE INDEX committed_identity ON identity_a(owner,code)') + expect(await readIdentities(k, sources)).toEqual(await original(k)) + expect(await readIdentities(k, sources)).not.toEqual(before) + } finally { + await k.destroy() + } +}) + +test('independent valid WAL DDL retains the pinned source view until it ends', async () => { + const folder = await mkdtemp(join(tmpdir(), 'ts544-identity-observations-')) + const filename = join(folder, 'owned.sqlite') + let k: Knex | undefined + let writer: Knex | undefined + try { + k = await fixture(filename) + writer = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + const independent = writer + const before = await readIdentities(k, sources) + await k.transaction(async t => { + await t('identity_a').select('id').limit(1) + await independent.raw('CREATE UNIQUE INDEX independent_identity ON identity_a(owner,code)') + expect(await readIdentities(t, sources)).toEqual(before) + expect(await original(t)).toEqual(before) + }) + expect(await readIdentities(k, sources)).toEqual(await original(k)) + expect(await readIdentities(k, sources)).not.toEqual(before) + } finally { + if (writer !== undefined) await writer.destroy() + if (k !== undefined) await k.destroy() + await rm(folder, { recursive: true, force: true }) + } +}) + +test('temporary source metadata has the exact original PRAGMA lookup and order', async () => { + const k = await fixture() + try { + await k.raw('CREATE TEMP TABLE identity_a(id INTEGER PRIMARY KEY,owner INTEGER NOT NULL,alias VARCHAR(20) UNIQUE)') + expect(await readIdentities(k, sources)).toEqual(await original(k)) + } finally { + await k.destroy() + } +}) + +test('the first unsupported numeric identity retains its original rejection identity and order', async () => { + const k = await fixture() + try { + await k.raw('DROP TABLE identity_a') + await k.raw('CREATE TABLE identity_a(id INTEGER,owner INTEGER,PRIMARY KEY(id,owner))') + await k.raw('DROP TABLE identity_b') + await expect(readIdentities(k, sources)).rejects.toBeInstanceOf(WERR_INVALID_OPERATION) + await expect(original(k)).rejects.toBeInstanceOf(WERR_INVALID_OPERATION) + await expect(readIdentities(k, sources)).rejects.toThrow('Unsupported numeric identity') + await expect(original(k)).rejects.toThrow('Unsupported numeric identity') + } finally { + await k.destroy() + } +}) + +test('nonoptimized driver selection retains the original individual PRAGMA results', async () => { + const k = await fixture() + const client = k.client.config.client + try { + k.client.config.client = 'sqlite3' + expect( + await readSqliteIdentityObservations( + k, + sources.map(source => source.table) + ) + ).toBeUndefined() + expect(await readIdentities(k, sources)).toEqual(await original(k)) + } finally { + k.client.config.client = client + await k.destroy() + } +}) + +test('empty and seventeen-source constructions retain the original fallback', async () => { + const k = await fixture() + try { + const extended = Array.from({ length: 17 }, () => ({ ...sources[0] })) + expect(await readSqliteIdentityObservations(k, [])).toBeUndefined() + expect(await readIdentities(k, [])).toEqual([]) + expect( + await readSqliteIdentityObservations( + k, + extended.map(source => source.table) + ) + ).toBeUndefined() + expect(await readIdentities(k, extended)).toEqual(await original(k, extended)) + } finally { + await k.destroy() + } +}) + +test('a healthy quoted source name retains the original PRAGMA path', async () => { + const k = await fixture() + try { + await k.raw('CREATE TABLE "identity spaced"(id INTEGER PRIMARY KEY,owner INTEGER NOT NULL)') + const quoted = [{ table: 'identity spaced', key: 'id', owner: 'owner' }] + expect( + await readSqliteIdentityObservations( + k, + quoted.map(source => source.table) + ) + ).toBeUndefined() + expect(await readIdentities(k, quoted)).toEqual(await original(k, quoted)) + } finally { + await k.destroy() + } +}) + +test('a source changed after observation selection falls back to its actual current table', async () => { + const k = await fixture() + try { + const current = [{ ...sources[0] }] + const change = (query: { sql: string }) => { + if (query.sql.includes('pragma_table_xinfo')) current[0].table = 'identity_b' + } + k.on('query', change) + const actual = await readIdentities(k, current) + k.off('query', change) + expect(actual).toEqual(await original(k, current)) + expect(actual[0].source.table).toBe('identity_b') + } finally { + await k.destroy() + } +}) + +test('mutating a returned metadata value cannot affect a subsequent construction', async () => { + const k = await fixture() + try { + const actual = await readIdentities(k, sources) + actual[0].columns[0].name = 'changed_returned_value' + expect(await readIdentities(k, sources)).toEqual(await original(k)) + } finally { + await k.destroy() + } +}) + +test('repeated source names retain independent ordered metadata groups', async () => { + const k = await fixture() + try { + const repeated = [sources[1], sources[0], sources[1]] + const actual = await readIdentities(k, repeated) + expect(actual).toEqual(await original(k, repeated)) + actual[0].columns[0].name = 'changed_first_group' + expect(actual[2].columns[0].name).toBe('id') + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.ts new file mode 100644 index 000000000..cf8630881 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.ts @@ -0,0 +1,48 @@ +import type { Knex } from 'knex' + +interface IdentityColumn { + name: string + type: string + pk: number + hidden: number +} +interface IdentityIndex { + name: string + unique: number + partial: number +} +export interface SqliteIdentityObservation { + table: string + columns: IdentityColumn[] + indexes: IdentityIndex[] +} + +/** Two fresh bounded SQLite statements. These rows never outlive one identity + * construction. Preserve original PRAGMA metadata fields and ordering. */ +export async function readSqliteIdentityObservations( + k: Knex, + sourceTables: string[] +): Promise { + const tables = [...sourceTables] + if ( + k.client.config.client !== 'better-sqlite3' || + tables.length === 0 || + tables.length > 16 || + !tables.every(table => typeof table === 'string' && /^[a-z_][a-z0-9_]*$/i.test(table)) + ) + return undefined + const selected = tables.map((_, index) => `SELECT ${index} ordinal, ? name`).join(' UNION ALL ') + const columns: Array = await k.raw( + `SELECT CAST(s.ordinal AS TEXT) sourceOrdinal,p.* FROM (${selected}) s CROSS JOIN pragma_table_xinfo(s.name) p ORDER BY s.ordinal,p.cid`, + tables + ) + const indexes: Array = await k.raw( + `SELECT CAST(s.ordinal AS TEXT) sourceOrdinal,p.* FROM (${selected}) s CROSS JOIN pragma_index_list(s.name) p ORDER BY s.ordinal,p.seq`, + tables + ) + const observed: SqliteIdentityObservation[] = tables.map(table => ({ table, columns: [], indexes: [] })) + const byOrdinal = new Map(observed.map((value, index) => [String(index), value])) + for (const { sourceOrdinal, ...column } of columns) byOrdinal.get(sourceOrdinal)?.columns.push(column) + for (const { sourceOrdinal, ...index } of indexes) byOrdinal.get(sourceOrdinal)?.indexes.push(index) + return observed +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts index 31f550766..5eb2ec4c3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts @@ -2,7 +2,7 @@ import { runInSeries } from '../../utility/runInSeries' import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' import { validateLegacy, legacySchema, retiredTables } from './snapshotSqliteLegacyOwnership' import type { Knex } from 'knex' -import { readIdentity, identityDDL, type IdentityDefinition } from './snapshotSqliteIdentity' +import { readIdentities, identityDDL, type IdentityDefinition } from './snapshotSqliteIdentity' import { numeric, relations, membershipTriggers, type MembershipNames } from './snapshotSqliteMembership' import { validateSqliteSource } from './snapshotGlobalIndexSqlite' import { snapshotGlobalIndexTriggers } from './snapshotGlobalIndexTriggers' @@ -134,14 +134,11 @@ function tables(fieldCollation: string): string[] { ...indexes.map(([name, table, columns]) => `CREATE INDEX ${q(name)} ON ${q(table)} (${columns.map(q).join(',')})`) ] } -export async function readPlan(k: Knex): Promise { +async function readSourcePlan(k: Knex): Promise> { if (!String(k.client.config.client).includes('sqlite')) throw new WERR_INVALID_OPERATION('SQLite rebuild requires SQLite') await validateSqliteSource(k) - const identities: IdentityDefinition[] = [] - await runInSeries(numeric, async source => { - identities.push(await readIdentity(k, source)) - }) + const identities = await readIdentities(k, numeric) await runInSeries(relations, async relation => { await compositeOrder(k, relation.table, [relation.leftKey, relation.rightKey]) }) @@ -157,6 +154,10 @@ export async function readPlan(k: Knex): Promise { .select('type', 'name', 'tbl_name', 'sql') .orderBy(['type', 'name']) const source = JSON.stringify(schema) + return { identities, fieldCollation, source } +} +export async function readPlan(k: Knex): Promise { + const { identities, fieldCollation, source } = await readSourcePlan(k) const ddl = [...tables(fieldCollation), ...identities.flatMap(identityDDL)] const observer = snapshotGlobalIndexTriggers(false) .filter(trigger => trigger.table === 'snapshot_global_edges') @@ -181,21 +182,56 @@ async function validateInstallLock(k: Knex): Promise { throw new WERR_INVALID_OPERATION('Invalid rebuild lock definition') } +interface InstalledDefinition { + type: string + name: string + sql: string +} +function installedDefinition(sql: string): InstalledDefinition | undefined { + const match = /^CREATE (TABLE|INDEX|TRIGGER) ("[^"]+"|\w+)/.exec(sql) + return match ? { type: match[1].toLowerCase(), name: match[2].replaceAll('"', ''), sql } : undefined +} +async function validateDefinition(k: Knex, sql: string): Promise { + const definition = installedDefinition(sql) + if (!definition) throw new WERR_INVALID_OPERATION('Invalid generated schema definition') + const rows: Array<{ sql: string }> = await k('sqlite_master') + .where({ type: definition.type, name: definition.name }) + .select('sql') + if (rows.length !== 1 || rows[0].sql !== sql) + throw new WERR_INVALID_OPERATION('Rebuild schema definition mismatch: ' + definition.name) +} +async function validateDefinitions(k: Knex, expected: string[]): Promise { + // Each read binds at most sixteen exact type/name pairs. Validation still + // compares every original SQL definition, in its original order. + const pages = Array.from({ length: Math.ceil(expected.length / 16) }, (_, index) => + expected.slice(index * 16, (index + 1) * 16) + ) + await runInSeries(pages, async page => { + const definitions = page.map(sql => (typeof sql === 'string' ? installedDefinition(sql) : undefined)) + if (definitions.some(definition => definition === undefined)) { + // Preserve the original error ordering for malformed caller-supplied plans. + await runInSeries(page, async sql => await validateDefinition(k, sql)) + return + } + const selected = definitions.filter((definition): definition is InstalledDefinition => definition !== undefined) + const rows: InstalledDefinition[] = await k('sqlite_master') + .where(query => { + for (const { type, name } of selected) void query.orWhere({ type, name }) + }) + .select('type', 'name', 'sql') + for (const definition of selected) { + const matches = rows.filter(row => row.type === definition.type && row.name === definition.name) + if (matches.length !== 1 || matches[0].sql !== definition.sql) + throw new WERR_INVALID_OPERATION('Rebuild schema definition mismatch: ' + definition.name) + } + }) +} /** Exact generated DDL ownership; never adopt a similarly named foreign object. */ export async function validateInstalled(k: Knex, plan: Plan): Promise { await validateInstallLock(k) - if ((await readPlan(k)).source !== plan.source) throw new WERR_INVALID_OPERATION('Rebuild source schema changed') - const expected = [...plan.ddl, ...plan.triggers] - await runInSeries(expected, async sql => { - const match = /^CREATE (TABLE|INDEX|TRIGGER) ("[^"]+"|\w+)/.exec(sql) - if (!match) throw new WERR_INVALID_OPERATION('Invalid generated schema definition') - const name = match[2].replaceAll('"', '') - const rows: Array<{ - sql: string - }> = await k('sqlite_master').where({ type: match[1].toLowerCase(), name }).select('sql') - if (rows.length !== 1 || rows[0].sql !== sql) - throw new WERR_INVALID_OPERATION('Rebuild schema definition mismatch: ' + name) - }) + if ((await readSourcePlan(k)).source !== plan.source) + throw new WERR_INVALID_OPERATION('Rebuild source schema changed') + await validateDefinitions(k, [...plan.ddl, ...plan.triggers]) const rows: Array<{ id: number source: string diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObserverColumns.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObserverColumns.test.ts new file mode 100644 index 000000000..7657c6dde --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObserverColumns.test.ts @@ -0,0 +1,176 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { WERR_INVALID_OPERATION } from '../../../sdk/WERR_errors' +import { fixture } from '../../../../test/utils/snapshotSqliteFixtures' +import { snapshotJournalSqliteObserverSql, snapshotJournalSqliteSources } from './SnapshotJournalSqliteObservers' + +// The retained sqlite3 branch executes the exact original sequential PRAGMA +// implementation on the same known healthy physical fixture. It is a fresh +// statement oracle, not a claim about a separate driver or platform. +async function freshStatements(k: Knex): Promise { + const original = k.client.config.client + k.client.config.client = 'sqlite3' + try { + return await snapshotJournalSqliteObserverSql(k) + } finally { + k.client.config.client = original + } +} +function queries(k: Knex): { values: string[]; stop: () => void } { + const values: string[] = [] + const observe = (query: { sql: string }) => values.push(query.sql) + k.on('query', observe) + return { + values, + stop: () => { + k.off('query', observe) + } + } +} + +test('one fresh bounded column statement preserves every original observer byte and source order', async () => { + const k = await fixture('BINARY', false) + try { + const seen = queries(k) + const actual = await snapshotJournalSqliteObserverSql(k) + expect(seen.values).toHaveLength(1) + expect(seen.values[0]).toContain('pragma_table_info') + seen.values.length = 0 + expect(await freshStatements(k)).toEqual(actual) + expect(seen.values).toHaveLength(snapshotJournalSqliteSources.length) + expect(seen.values.every(sql => sql.startsWith('PRAGMA table_info'))).toBe(true) + seen.stop() + } finally { + await k.destroy() + } +}) + +test('valid schema changes and rollback are observed afresh without retained schema rows', async () => { + const k = await fixture('BINARY', false) + try { + const original = await snapshotJournalSqliteObserverSql(k) + await expect( + k.transaction(async t => { + await t.raw('ALTER TABLE outputs ADD COLUMN extraColumn TEXT') + const actual = await snapshotJournalSqliteObserverSql(t) + expect(actual).toEqual(await freshStatements(t)) + expect(actual).not.toEqual(original) + throw new Error('ordinary rollback') + }) + ).rejects.toThrow('ordinary rollback') + expect(await snapshotJournalSqliteObserverSql(k)).toEqual(original) + await k.raw('ALTER TABLE outputs ADD COLUMN committedColumn TEXT') + const committed = await snapshotJournalSqliteObserverSql(k) + expect(committed).toEqual(await freshStatements(k)) + expect(committed).not.toEqual(original) + } finally { + await k.destroy() + } +}) + +test('independent valid DDL respects the real WAL read view and becomes visible after it ends', async () => { + const folder = await mkdtemp(join(tmpdir(), 'ts544-columns-')) + const filename = join(folder, 'owned.sqlite') + let writer: Knex | undefined + let k: Knex | undefined + try { + k = await fixture('BINARY', false, true, filename) + writer = knex({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + }) + const independent = writer + const original = await snapshotJournalSqliteObserverSql(k) + await k.transaction(async t => { + await t('outputs').select('outputId').limit(1) + expect(await snapshotJournalSqliteObserverSql(t)).toEqual(original) + await independent.raw('ALTER TABLE outputs ADD COLUMN independentColumn TEXT') + expect(await snapshotJournalSqliteObserverSql(t)).toEqual(original) + expect(await freshStatements(t)).toEqual(original) + }) + const after = await snapshotJournalSqliteObserverSql(k) + expect(after).toEqual(await freshStatements(k)) + expect(after).not.toEqual(original) + } finally { + if (writer !== undefined) await writer.destroy() + if (k !== undefined) await k.destroy() + await rm(folder, { recursive: true, force: true }) + } +}) + +test('missing source rejection keeps the original error identity', async () => { + const k = await fixture('BINARY', false) + try { + await k.raw('DROP TABLE outputs') + await k.raw('DROP TABLE certificate_fields') + await expect(snapshotJournalSqliteObserverSql(k)).rejects.toBeInstanceOf(WERR_INVALID_OPERATION) + await expect(freshStatements(k)).rejects.toBeInstanceOf(WERR_INVALID_OPERATION) + await expect(snapshotJournalSqliteObserverSql(k)).rejects.toThrow('Missing snapshot journal source') + await expect(freshStatements(k)).rejects.toThrow('Missing snapshot journal source') + } finally { + await k.destroy() + } +}) + +test('fresh statement results preserve caller safe-integer settings', async () => { + const k = await fixture('BINARY', false) + const connection: { defaultSafeIntegers: (enabled: boolean) => unknown } = await k.client.acquireConnection() + try { + connection.defaultSafeIntegers(true) + await k.client.releaseConnection(connection) + expect(await snapshotJournalSqliteObserverSql(k)).toEqual(await freshStatements(k)) + } finally { + connection.defaultSafeIntegers(false) + await k.destroy() + } +}) + +test('temporary schema lookup matches original fresh PRAGMA lookup', async () => { + const k = await fixture('BINARY', false) + try { + const original = await snapshotJournalSqliteObserverSql(k) + await k.raw('CREATE TEMP TABLE outputs (temporaryColumn TEXT, secondColumn INTEGER)') + expect(await snapshotJournalSqliteObserverSql(k)).toEqual(await freshStatements(k)) + await k.raw('DROP TABLE temp.outputs') + expect(await snapshotJournalSqliteObserverSql(k)).toEqual(original) + } finally { + await k.destroy() + } +}) + +test('a source list beyond the new sixteen-table optimization bound retains original sequential reads', async () => { + const k = await fixture('BINARY', false) + const originalLength = snapshotJournalSqliteSources.length + try { + snapshotJournalSqliteSources.push('outputs', 'outputs', 'outputs', 'outputs') + const seen = queries(k) + const actual = await snapshotJournalSqliteObserverSql(k) + expect(seen.values).toHaveLength(17) + expect(seen.values.every(sql => sql.startsWith('PRAGMA table_info'))).toBe(true) + seen.stop() + expect(actual).toEqual(await freshStatements(k)) + } finally { + snapshotJournalSqliteSources.splice(originalLength) + await k.destroy() + } +}) + +// The exported legacy array remains mutable. Query observers must not turn a +// detached binding into stale column authority for a changed table selection. +test('a source selection changed during a query uses fresh matching columns', async () => { + const k = await fixture('BINARY', false) + const original = snapshotJournalSqliteSources[1] + try { + k.once('query', () => { + snapshotJournalSqliteSources[1] = 'commissions' + }) + expect(await snapshotJournalSqliteObserverSql(k)).toEqual(await freshStatements(k)) + } finally { + snapshotJournalSqliteSources[1] = original + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts index 805fe4177..8bcb8ef86 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalSqliteObservers.ts @@ -95,6 +95,38 @@ function physicalTrigger(tableId: number, table: string, event: string, changed: return `CREATE TRIGGER snapshot_journal_physical_${tableId}_${event} AFTER ${event} ON ${q(table)} ${event === 'UPDATE' ? 'WHEN ' + changed : ''} BEGIN ${body} END` } +/** Read one bounded group of table columns in one fresh SQLite statement. + * The result belongs only to this observer construction; it is never retained + * as schema, generation or commit authority. Other drivers retain the original + * per-table PRAGMA path. */ +interface ObservedColumns { + table: string + columns: Array<{ name: string }> +} +async function readObserverColumns(k: Knex): Promise { + const tables = [...snapshotJournalSqliteSources] + if (k.client.config.client !== 'better-sqlite3' || tables.length === 0 || tables.length > 16) return undefined + const selected = tables.map((_, i) => `SELECT ${i} ordinal, ? name`).join(' UNION ALL ') + const rows: Array<{ ordinal: string; name: string }> = await k.raw( + `SELECT CAST(s.ordinal AS TEXT) ordinal,p.name FROM (${selected}) s CROSS JOIN pragma_table_info(s.name) p ORDER BY s.ordinal,p.cid`, + tables + ) + const observed: ObservedColumns[] = tables.map(table => ({ table, columns: [] })) + const byOrdinal = new Map(observed.map((value, index) => [String(index), value])) + for (const { ordinal, name } of rows) byOrdinal.get(ordinal)?.columns.push({ name }) + return observed +} + +async function observerColumns( + k: Knex, + observed: ObservedColumns[] | undefined, + tableId: number, + table: string +): Promise> { + const selected = observed?.[tableId] + return selected?.table === table ? selected.columns : await k.raw('PRAGMA table_info(??)', [table]) +} + /** Prepare observers only after the caller validates the completed v2 source generation. */ export async function snapshotJournalSqliteObserverSql(k: Knex): Promise { if (!['sqlite3', 'better-sqlite3'].includes(k.client.config.client)) @@ -141,8 +173,9 @@ export async function snapshotJournalSqliteObserverSql(k: Knex): Promise { - const columns: Array<{ name: string }> = await k.raw('PRAGMA table_info(??)', [table]) + const columns: Array<{ name: string }> = await observerColumns(k, observed, tableId, table) if (columns.length === 0) throw new WERR_INVALID_OPERATION('Missing snapshot journal source') const changed = columns .map(({ name }) => `CAST(OLD.${q(name)} AS BLOB) IS NOT CAST(NEW.${q(name)} AS BLOB)`) diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index e98ac9811..c82ec3aea 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -43,6 +43,7 @@ const plans = new Map([ ['src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'global-bootstrap'], ['src/storage/schema/snapshotGlobalIndexTriggers.ts', 'global-triggers'], ['src/storage/schema/snapshotSqliteIdentity.ts', 'sqlite-identity'], + ['src/storage/schema/snapshotSqliteIdentityObservations.ts', 'sqlite-identity'], ['src/storage/schema/snapshotSqliteMembership.ts', 'sqlite-membership'], ['src/storage/schema/snapshotSqliteIndexGeneration.ts', 'sqlite-generation'], ['src/storage/schema/snapshotSqliteIndexBootstrap.ts', 'sqlite-bootstrap'], From fc9748e30c910e81623b07de4f36c673b5543d30 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 5 Oct 2026 14:34:28 -0700 Subject: [PATCH 120/127] fix(wallet): use exact undefined membership predicate --- .../src/storage/schema/snapshotSqliteIndexGeneration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts index 5eb2ec4c3..1ecac322b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIndexGeneration.ts @@ -208,7 +208,7 @@ async function validateDefinitions(k: Knex, expected: string[]): Promise { ) await runInSeries(pages, async page => { const definitions = page.map(sql => (typeof sql === 'string' ? installedDefinition(sql) : undefined)) - if (definitions.some(definition => definition === undefined)) { + if (definitions.includes(undefined)) { // Preserve the original error ordering for malformed caller-supplied plans. await runInSeries(page, async sql => await validateDefinition(k, sql)) return From 9aa78e3c9aaf7fd135903ea901edd54a9aaaa6ff Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 5 Oct 2026 15:12:48 -0700 Subject: [PATCH 121/127] test(wallet): preserve SQLite observer prerequisite contracts --- .../SnapshotJournalPrerequisites.test.ts | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts index cd9cd8f41..0f20c2ea6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/snapshot/journal/SnapshotJournalPrerequisites.test.ts @@ -5,7 +5,7 @@ import { import { knex, type Knex } from 'knex' import { copySnapshotJournalBootstrapPage } from './SnapshotJournalBootstrap' import { readSnapshotJournalHighWater } from './SnapshotJournalHighWater' -import { snapshotJournalSqliteObserverSql } from './SnapshotJournalSqliteObservers' +import { snapshotJournalSqliteObserverSql, snapshotJournalSqliteSources } from './SnapshotJournalSqliteObservers' import { snapshotJournalMysqlObserverSql } from './SnapshotJournalMysqlObservers' import { snapshotJournalRevision as rev } from './SnapshotJournalRevision' @@ -30,22 +30,33 @@ test.each([ await k.destroy() } }) -test('SQLite observer preparation refuses a missing source without any schema writes', async () => { - const k = knex({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true - }), - queries: string[] = [] - k.on('query', q => queries.push(q.sql)) - try { - await expect(snapshotJournalSqliteObserverSql(k)).rejects.toThrow('Missing snapshot journal source') - expect(queries).toEqual(['PRAGMA table_info(`transactions`)']) - expect(await k('sqlite_master').select('name')).toEqual([]) - } finally { - await k.destroy() +test.each(['better-sqlite3', 'sqlite3'] as const)( + '%s observer preparation refuses a missing source without any schema writes', + async driver => { + const k = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }), + queries: Array<{ sql: string; bindings: unknown[] }> = [] + k.client.config.client = driver + k.on('query', q => queries.push({ sql: q.sql, bindings: q.bindings })) + try { + await expect(snapshotJournalSqliteObserverSql(k)).rejects.toThrow('Missing snapshot journal source') + expect(queries).toHaveLength(1) + if (driver === 'better-sqlite3') { + expect(queries[0].sql).toMatch(/^SELECT /) + expect(queries[0].sql).toContain('CROSS JOIN pragma_table_info(s.name)') + expect(queries[0].bindings).toEqual(snapshotJournalSqliteSources) + } else { + expect(queries[0].sql).toBe('PRAGMA table_info(`transactions`)') + } + expect(await k('sqlite_master').select('name')).toEqual([]) + } finally { + await k.destroy() + } } -}) +) test('MySQL observer preparation refuses missing driver metadata without emitting DDL', async () => { const raw = jest.fn(async () => [[]]), k = { client: { config: { client: 'mysql2' } }, raw } as unknown as Knex From 56bc989684c460f729c0577af7cb4b1c22cf7c13 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 5 Oct 2026 16:03:44 -0700 Subject: [PATCH 122/127] perf(wallet): group fresh metadata for pinned global validation --- docs/reference/package-api-migrations.md | 2 +- governance/mutation-testing/targets.mjs | 2 + governance/package-release-notes.json | 2 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 4 + packages/wallet/wallet-toolbox/README.md | 6 +- .../schema/snapshotGlobalIndexMigration.ts | 14 +- .../schema/snapshotGlobalIndexSqlite.ts | 90 ++++- ...snapshotSqliteIdentityObservations.test.ts | 71 +++- .../snapshotSqliteIdentityObservations.ts | 36 ++ .../snapshotSqliteSchemaObservations.test.ts | 322 ++++++++++++++++++ .../snapshotSqliteSchemaObservations.ts | 54 +++ scripts/mutation-partitions.mjs | 1 + 12 files changed, 583 insertions(+), 21 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteSchemaObservations.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteSchemaObservations.ts diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 7045632aa..f611af33f 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -514,7 +514,7 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. +- Release note: Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. Global-index validation groups fresh bounded table and index metadata inside pinned SQLite transactions while preserving individual driver/oversized/unpinned fallbacks, public validator arity, partial/descending-index rejection and EXPLAIN checks. - Migration: These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index ad198bf70..8b3b79dec 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -583,6 +583,7 @@ export function buildMutationTargets(repositoryRoot) { 'src/storage/schema/snapshotGlobalIndexModel.ts', 'src/storage/schema/snapshotGlobalIndexMysql.ts', 'src/storage/schema/snapshotGlobalIndexSqlite.ts', + 'src/storage/schema/snapshotSqliteSchemaObservations.ts', 'src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'src/storage/schema/snapshotGlobalIndexTriggers.ts', 'src/storage/schema/snapshotSqliteIdentity.ts', @@ -635,6 +636,7 @@ export function buildMutationTargets(repositoryRoot) { [ '/src/storage/snapshot/*.test.ts', '/src/storage/schema/snapshotSqliteIdentityObservations.test.ts', + '/src/storage/schema/snapshotSqliteSchemaObservations.test.ts', '/src/storage/schema/snapshotSqliteDefinitions.test.ts', '/src/storage/snapshot/journal/SnapshotJournalCapture*.test.ts', '/src/storage/snapshot/journal/SnapshotJournalConnections.test.ts', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index a060ff862..ab71fe97e 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,7 +210,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.4", "releaseType": "minor", - "summary": "Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract.", + "summary": "Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. Global-index validation groups fresh bounded table and index metadata inside pinned SQLite transactions while preserving individual driver/oversized/unpinned fallbacks, public validator arity, partial/descending-index rejection and EXPLAIN checks.", "migration": "These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews." }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 0147e33ed..768659efa 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -11,6 +11,10 @@ attention to changes that materially alter behavior or extend functionality. of at most sixteen. Preserve exact metadata, DDL and validation/error order; no schema cache, API, wire or database migration is required. Full #544 production qualification remains open. + Global-index validation now groups fresh table and index metadata inside its + pinned SQLite transaction, with individual reads outside a transaction and for + unsupported drivers or oversized groups. Public validator arity, MySQL behavior, + partial/descending-index rejection and `EXPLAIN` checks remain unchanged. - Preserve queued primary reselection: a request to select A during a pending switch from A to B waits for that switch and then selects A, including after diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index d25144ce3..3c23fcd43 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -198,7 +198,11 @@ SQLite identity and journal-observer validation read fresh bounded metadata page for each operation. Metadata is grouped in one pass, and installed schema definitions are checked in pages of at most sixteen. Exact metadata, DDL, validation order and error identities are retained; there is no cross-operation -schema cache. The existing schema and BRC-38/39 formats need no migration. +schema cache. The existing schema and BRC-38/39 formats need no migration. Global-index +validation groups fresh table and index metadata only inside its pinned SQLite +transaction. Individual reads remain the fallback outside a transaction, for +unsupported drivers or oversized groups; the public table validator retains its +original three-argument contract. The complete sync/streaming/restore program remains in progress on #569. ## Backup and sync: tested results diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts index 95f4b6534..b701b830a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexMigration.ts @@ -3,7 +3,7 @@ import { runInSeries } from '../../utility/runInSeries' import { snapshotGlobalIndexTriggers, type SnapshotGlobalIndexTrigger } from './snapshotGlobalIndexTriggers' import { tables, PROGRESS, mysql, normalized, invalid, type Table } from './snapshotGlobalIndexModel' import { mysqlParts, mysqlTable, validateMysqlSource } from './snapshotGlobalIndexMysql' -import { sqliteTable, validateSqliteSource } from './snapshotGlobalIndexSqlite' +import { sqliteTable, validateSqliteSource, validateSqliteTables } from './snapshotGlobalIndexSqlite' import { validPosition, bootstrapPage, type Position } from './snapshotGlobalIndexBootstrap' export const SNAPSHOT_GLOBAL_INDEX_MIGRATION = '2026-10-01-006 add snapshot global reference indexes' @@ -41,6 +41,13 @@ async function validateSource(k: Knex): Promise { if (mysql(k)) await validateMysqlSource(k) else await validateSqliteSource(k) } +async function validateGlobalTables(k: Knex): Promise { + if (!mysql(k)) return await validateSqliteTables(k, tables()) + await runInSeries(tables(), async table => { + if (!(await k.schema.hasTable(table.name))) invalid('Snapshot global index migration is incomplete') + await validateTable(k, table) + }) +} async function validateTrigger(k: Knex, expected: SnapshotGlobalIndexTrigger): Promise { if (!mysql(k)) { const row: { sql: string } | undefined = await k('sqlite_master') @@ -114,10 +121,7 @@ export async function readSnapshotGlobalIndexState(k: Knex, config?: Knex.Migrat if (config?.schemaName !== undefined) void journal.withSchema(config.schemaName) if ((await journal.first('name')) === undefined) return false await validateSource(k) - await runInSeries(tables(), async table => { - if (!(await k.schema.hasTable(table.name))) invalid('Snapshot global index migration is incomplete') - await validateTable(k, table) - }) + await validateGlobalTables(k) const states: Array = await k(PROGRESS).select('*').limit(2) if ( states.length !== 1 || diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts index 20c63a522..ba8171c7b 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotGlobalIndexSqlite.ts @@ -1,8 +1,10 @@ import type { Knex } from 'knex' import { runInSeries } from '../../utility/runInSeries' import { invalid, sources, type Table, type Index } from './snapshotGlobalIndexModel' +import { readSqliteSchemaObservations, type SqliteSchemaObservation } from './snapshotSqliteSchemaObservations' +import { readSqliteIndexObservations, type SqliteIndexObservation } from './snapshotSqliteIdentityObservations' interface SqlitePart { - name: string + name: string | null desc: number coll: string key: number @@ -12,6 +14,28 @@ async function sqliteParts(k: Knex, name: string): Promise { return rows.filter(row => row.key === 1) } export async function sqliteTable(k: Knex, table: Table, secondary: boolean): Promise { + return await sqliteObservedTable(k, table, secondary) +} +async function globalIndexParts( + k: Knex, + required: Index[], + indexes: Array<{ name: string; partial: number }> +): Promise { + if (!k.isTransaction) return undefined + const selected = required.map(index => indexes.find(value => value.name === index.name)) + if (!selected.every(index => index?.partial === 0)) return undefined + return await readSqliteIndexObservations( + k, + selected.map(index => index!.name) + ) +} +async function sqliteObservedTable( + k: Knex, + table: Table, + secondary: boolean, + observed?: SqliteSchemaObservation +): Promise { + const observation = observed?.table === table.name ? observed : undefined const actual: Array<{ name: string type: string @@ -19,7 +43,7 @@ export async function sqliteTable(k: Knex, table: Table, secondary: boolean): Pr dflt_value: unknown pk: number hidden: number - }> = await k.raw('PRAGMA table_xinfo(??)', [table.name]) + }> = observation?.columns ?? (await k.raw('PRAGMA table_xinfo(??)', [table.name])) const types = { int: 'integer', uint: 'integer', @@ -45,7 +69,7 @@ export async function sqliteTable(k: Knex, table: Table, secondary: boolean): Pr unique: number origin: string partial: number - }> = await k.raw('PRAGMA index_list(??)', [table.name]) + }> = observation?.indexes ?? (await k.raw('PRAGMA index_list(??)', [table.name])) if (indexes.some(index => index.unique !== 0 && (index.origin !== 'pk' || index.partial !== 0))) return false const required: Index[] = secondary ? [...table.indexes] : [] if (table.primary.length > 1) { @@ -53,10 +77,15 @@ export async function sqliteTable(k: Knex, table: Table, secondary: boolean): Pr if (primary === undefined) return false required.push({ name: primary.name, columns: table.primary }) } - for (const index of required) { + const observedParts = observation === undefined ? undefined : await globalIndexParts(k, required, indexes) + for (const [position, index] of required.entries()) { const found = indexes.find(value => value.name === index.name) if (found?.partial !== 0) return false - const parts = await sqliteParts(k, found.name) + const selectedParts = observedParts?.[position] + const parts = + selectedParts?.name === found.name + ? selectedParts.parts.filter(part => part.key === 1) + : await sqliteParts(k, found.name) if ( parts.length !== index.columns.length || parts.some((part, i) => part.name !== index.columns[i] || part.desc !== 0 || part.coll !== 'BINARY') @@ -65,13 +94,24 @@ export async function sqliteTable(k: Knex, table: Table, secondary: boolean): Pr } return true } -async function sqliteTextOrder(k: Knex, table: string): Promise { - const indexes: Array<{ name: string; unique: number; partial: number }> = await k.raw('PRAGMA index_list(??)', [ - table - ]) +async function sqliteTextOrder(k: Knex, table: string, observed?: SqliteSchemaObservation): Promise { + const indexes: Array<{ name: string; unique: number; partial: number }> = + observed?.table === table ? observed.indexes : await k.raw('PRAGMA index_list(??)', [table]) + const eligible = indexes.filter(index => index.partial === 0 && (table !== 'proven_tx_reqs' || index.unique === 1)) + const observedParts = + observed?.table === table + ? await readSqliteIndexObservations( + k, + eligible.map(index => index.name) + ) + : undefined for (const index of indexes) { if (index.partial !== 0 || (table === 'proven_tx_reqs' && index.unique !== 1)) continue - const parts = await sqliteParts(k, index.name) + const selectedParts = observedParts?.find(value => value.name === index.name) + const parts = + selectedParts === undefined + ? await sqliteParts(k, index.name) + : selectedParts.parts.filter(part => part.key === 1) if ( parts[0]?.name !== 'txid' || parts[0].desc !== 0 || @@ -89,14 +129,19 @@ async function sqliteTextOrder(k: Knex, table: string): Promise { } export async function validateSqliteSource(k: Knex): Promise { let collation: string | undefined + const observed = await readSqliteSchemaObservations( + k, + sources.map(source => source.name) + ) await runInSeries(sources, async source => { + const observation = observed?.find(value => value.table === source.name) const actual: Array<{ name: string type: string notnull: number pk: number hidden: number - }> = await k.raw('PRAGMA table_xinfo(??)', [source.name]) + }> = observation?.columns ?? (await k.raw('PRAGMA table_xinfo(??)', [source.name])) const primary = actual.filter(column => column.pk !== 0) if (primary.length !== 1 || primary[0]?.name !== source.key || primary[0].pk !== 1) invalid('Unsupported snapshot global source key') @@ -110,10 +155,31 @@ export async function validateSqliteSource(k: Knex): Promise { invalid('Unsupported snapshot global source column') } if (source.name !== 'proven_txs') { - const order = await sqliteTextOrder(k, source.name) + const order = await sqliteTextOrder(k, source.name, observation) if (collation !== undefined && collation !== order) invalid('Snapshot global source comparisons require matching text definitions') collation = order } }) } + +/** Same presence/definition error ordering as the original per-table loop. + * Observations are created here and never accepted from a caller. */ +export async function validateSqliteTables(k: Knex, definitions: Table[]): Promise { + const observed = await readSqliteSchemaObservations( + k, + definitions.map(table => table.name) + ) + await runInSeries(definitions, async table => { + if (!(await k.schema.hasTable(table.name))) invalid('Snapshot global index migration is incomplete') + if ( + !(await sqliteObservedTable( + k, + table, + true, + observed?.find(value => value.table === table.name) + )) + ) + invalid('Snapshot global table definition mismatch') + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.test.ts index 2ec0e10d6..203b10ca9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' import { readIdentity, readIdentities, identityDDL, type SourceIdentity } from './snapshotSqliteIdentity' -import { readSqliteIdentityObservations } from './snapshotSqliteIdentityObservations' +import { readSqliteIdentityObservations, readSqliteIndexObservations } from './snapshotSqliteIdentityObservations' const sources: SourceIdentity[] = [ { table: 'identity_a', key: 'id', owner: 'owner' }, @@ -228,3 +228,72 @@ test('repeated source names retain independent ordered metadata groups', async ( await k.destroy() } }) + +test('two bounded index names use one fresh exact index-part group', async () => { + const k = await fixture() + try { + await k.raw('CREATE UNIQUE INDEX "identity quoted index" ON identity_a(owner,code COLLATE RTRIM)') + const queries: string[] = [] + const observe = (query: { sql: string }) => queries.push(query.sql) + const names = ((await k.raw('PRAGMA index_list(identity_a)')) as Array<{ name: string; unique: number }>) + .filter(row => row.unique !== 0) + .map(row => row.name) + k.on('query', observe) + const observed = await readSqliteIndexObservations(k, names) + k.off('query', observe) + const actual = await readIdentities(k, [sources[0]]) + expect(queries.filter(sql => sql.includes('pragma_index_xinfo'))).toHaveLength(1) + expect(queries.filter(sql => sql.startsWith('PRAGMA index_xinfo'))).toHaveLength(0) + expect(actual).toEqual(await original(k, [sources[0]])) + expect(actual.map(identityDDL)).toEqual((await original(k, [sources[0]])).map(identityDDL)) + for (const [index, name] of names.entries()) { + expect(observed?.[index].parts).toEqual(await k.raw('PRAGMA index_xinfo(??)', [name])) + expect(observed?.[index].parts[0]).not.toHaveProperty('sourceOrdinal') + } + } finally { + await k.destroy() + } +}) + +test('seventeen current unique constraints retain individual fresh index reads', async () => { + const k = await fixture() + try { + for (let index = 0; index < 16; index++) + await k.raw('CREATE UNIQUE INDEX ?? ON identity_a(owner,code)', ['extra_' + index]) + const names = ((await k.raw('PRAGMA index_list(identity_a)')) as Array<{ name: string }>).map(row => row.name) + expect(names).toHaveLength(17) + expect(await readSqliteIndexObservations(k, names)).toBeUndefined() + expect(await readIdentities(k, [sources[0]])).toEqual(await original(k, [sources[0]])) + } finally { + await k.destroy() + } +}) + +test('empty and nonoptimized index groups preserve fallback without executing metadata SQL', async () => { + const k = await fixture(), + client = k.client.config.client + try { + expect(await readSqliteIndexObservations(k, [])).toBeUndefined() + k.client.config.client = 'sqlite3' + expect(await readSqliteIndexObservations(k, ['sqlite_autoindex_identity_a_1'])).toBeUndefined() + } finally { + k.client.config.client = client + await k.destroy() + } +}) + +test('returned index metadata is independent across duplicate groups and future observations', async () => { + const k = await fixture() + try { + const names = ['sqlite_autoindex_identity_a_1', 'sqlite_autoindex_identity_a_1'] + const observed = await readSqliteIndexObservations(k, names) + expect(observed).toBeDefined() + observed![0].parts[0].name = 'mutated_returned_part' + expect(observed![1].parts[0].name).toBe('code') + expect((await readSqliteIndexObservations(k, names))?.[0].parts).toEqual( + await k.raw('PRAGMA index_xinfo(??)', [names[0]]) + ) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.ts index cf8630881..fd0ad15dc 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteIdentityObservations.ts @@ -46,3 +46,39 @@ export async function readSqliteIdentityObservations( for (const { sourceOrdinal, ...index } of indexes) byOrdinal.get(sourceOrdinal)?.indexes.push(index) return observed } + +interface IdentityIndexPart { + name: string | null + desc: number + coll: string + key: number +} +export interface SqliteIndexObservation { + name: string + parts: IdentityIndexPart[] +} + +/** One fresh bounded group after the original numeric/partial validation. + * These index rows only belong to the current observation. */ +export async function readSqliteIndexObservations( + k: Knex, + indexNames: string[] +): Promise { + const names = [...indexNames] + if ( + k.client.config.client !== 'better-sqlite3' || + names.length === 0 || + names.length > 16 || + !names.every(name => typeof name === 'string') + ) + return undefined + const selected = names.map((_, index) => `SELECT ${index} ordinal, ? name`).join(' UNION ALL ') + const rows: Array = await k.raw( + `SELECT CAST(s.ordinal AS TEXT) sourceOrdinal,p.* FROM (${selected}) s CROSS JOIN pragma_index_xinfo(s.name) p ORDER BY s.ordinal,p.seqno`, + names + ) + const observed: SqliteIndexObservation[] = names.map(name => ({ name, parts: [] })) + const byOrdinal = new Map(observed.map((value, index) => [String(index), value])) + for (const { sourceOrdinal, ...part } of rows) byOrdinal.get(sourceOrdinal)?.parts.push(part) + return observed +} diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteSchemaObservations.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteSchemaObservations.test.ts new file mode 100644 index 000000000..3ea965668 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteSchemaObservations.test.ts @@ -0,0 +1,322 @@ +import { knex, type Knex } from 'knex' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createGlobalSource, minimalGlobalDatabase } from '../../../test/utils/snapshotGlobalFixtures' +import { runInSeries } from '../../utility/runInSeries' +import { tables } from './snapshotGlobalIndexModel' +import { sqliteTable, validateSqliteSource, validateSqliteTables } from './snapshotGlobalIndexSqlite' +import { + addSnapshotGlobalIndexes, + readSnapshotGlobalIndexState, + SNAPSHOT_GLOBAL_INDEX_MIGRATION +} from './snapshotGlobalIndexMigration' +import { readSqliteSchemaObservations } from './snapshotSqliteSchemaObservations' + +const names = ['proven_txs', 'proven_tx_reqs', 'transactions'] + +async function individual(k: Knex, selected = names) { + const result = [] + for (const table of selected) + result.push({ + table, + columns: await k.raw('PRAGMA table_xinfo(??)', [table]), + indexes: await k.raw('PRAGMA index_list(??)', [table]) + }) + return result +} + +test('pinned metadata preserves every individual PRAGMA field, table and row order', async () => { + const k = await minimalGlobalDatabase() + try { + await k.transaction(async t => { + const queries: string[] = [] + const listener = (query: { sql: string }) => queries.push(query.sql) + t.on('query', listener) + const observed = await readSqliteSchemaObservations(t, names) + t.off('query', listener) + expect(queries).toHaveLength(2) + expect(observed).toEqual(await individual(t)) + expect(observed?.[0].columns[0]).toHaveProperty('dflt_value') + expect(observed?.[1].indexes[0]).toHaveProperty('origin') + expect(observed?.[0].columns[0]).not.toHaveProperty('sourceOrdinal') + const repeated = [names[2], names[0], names[2]] + expect(await readSqliteSchemaObservations(t, repeated)).toEqual(await individual(t, repeated)) + }) + } finally { + await k.destroy() + } +}) + +test('outside transactions, unsupported drivers and names retain the original read path', async () => { + const k = await minimalGlobalDatabase() + try { + expect(await readSqliteSchemaObservations(k, names)).toBeUndefined() + await validateSqliteSource(k) + await k.transaction(async t => { + expect(await readSqliteSchemaObservations(t, [])).toBeUndefined() + expect( + await readSqliteSchemaObservations( + t, + Array.from({ length: 17 }, () => names[0]) + ) + ).toBeUndefined() + expect( + await readSqliteSchemaObservations( + t, + Array.from({ length: 16 }, () => names[0]) + ) + ).toEqual( + await individual( + t, + Array.from({ length: 16 }, () => names[0]) + ) + ) + await t.raw('CREATE TABLE "quoted source"(id INTEGER PRIMARY KEY)') + expect(await readSqliteSchemaObservations(t, ['quoted source'])).toBeUndefined() + const client = t.client.config.client + try { + t.client.config.client = 'sqlite3' + expect(await readSqliteSchemaObservations(t, names)).toBeUndefined() + await validateSqliteSource(t) + } finally { + t.client.config.client = client + } + }) + } finally { + await k.destroy() + } +}) + +test('valid committed DDL and rolled-back DDL are observed afresh', async () => { + const k = await minimalGlobalDatabase() + try { + const before = await k.transaction(t => readSqliteSchemaObservations(t, names)) + await expect( + k.transaction(async t => { + await t.raw('CREATE INDEX rollback_source ON transactions(userId)') + expect(await readSqliteSchemaObservations(t, names)).toEqual(await individual(t)) + expect(await readSqliteSchemaObservations(t, names)).not.toEqual(before) + throw new Error('owned ordinary rollback') + }) + ).rejects.toThrow('owned ordinary rollback') + expect(await k.transaction(t => readSqliteSchemaObservations(t, names))).toEqual(before) + await k.raw('CREATE INDEX committed_source ON transactions(userId)') + await k.transaction(async t => { + expect(await readSqliteSchemaObservations(t, names)).toEqual(await individual(t)) + expect(await readSqliteSchemaObservations(t, names)).not.toEqual(before) + await validateSqliteSource(t) + }) + } finally { + await k.destroy() + } +}) + +test('temporary source precedence remains identical to individual PRAGMAs', async () => { + const k = await minimalGlobalDatabase() + try { + await k.transaction(async t => { + await t.raw('CREATE TEMP TABLE proven_txs(provenTxId INTEGER PRIMARY KEY,extra INTEGER)') + expect(await readSqliteSchemaObservations(t, names)).toEqual(await individual(t)) + }) + } finally { + await k.destroy() + } +}) + +test('an independent WAL writer cannot change a pinned metadata view', async () => { + const folder = await mkdtemp(join(tmpdir(), 'ts544-global-observations-')) + const options = { + client: 'better-sqlite3', + connection: { filename: join(folder, 'owned.sqlite') }, + useNullAsDefault: true, + pool: { min: 0, max: 1 } + } + const k = knex(options) + const writer = knex(options) + try { + await k.raw('PRAGMA journal_mode=WAL') + await createGlobalSource(k) + const before = await k.transaction(t => readSqliteSchemaObservations(t, names)) + await k.transaction(async t => { + expect(await readSqliteSchemaObservations(t, names)).toEqual(before) + await writer.raw('CREATE INDEX independent_source ON transactions(userId)') + expect(await readSqliteSchemaObservations(t, names)).toEqual(before) + expect(await individual(t)).toEqual(before) + }) + await k.transaction(async t => { + expect(await readSqliteSchemaObservations(t, names)).toEqual(await individual(t)) + expect(await readSqliteSchemaObservations(t, names)).not.toEqual(before) + }) + } finally { + await writer.destroy() + await k.destroy() + await rm(folder, { recursive: true, force: true }) + } +}) + +test('complete global state uses two fresh bounded metadata groups', async () => { + const k = await minimalGlobalDatabase() + try { + await addSnapshotGlobalIndexes(k) + await k.schema.createTable('knex_migrations', table => table.string('name')) + await k('knex_migrations').insert({ name: SNAPSHOT_GLOBAL_INDEX_MIGRATION }) + expect(await readSnapshotGlobalIndexState(k)).toBe(true) + expect(sqliteTable).toHaveLength(3) + await k.transaction(async t => { + const queries: string[] = [] + const listener = (query: { sql: string }) => queries.push(query.sql) + t.on('query', listener) + expect(await readSnapshotGlobalIndexState(t)).toBe(true) + t.off('query', listener) + expect(queries.filter(sql => sql.includes('pragma_table_xinfo'))).toHaveLength(2) + expect(queries.filter(sql => sql.includes('pragma_index_list'))).toHaveLength(2) + await runInSeries(tables(), async table => { + expect(await sqliteTable(t, table, true)).toBe(true) + }) + }) + } finally { + await k.destroy() + } +}) + +test('source validation retains the first original key rejection before later tables', async () => { + const k = await minimalGlobalDatabase() + try { + await k.raw('DROP TABLE proven_txs') + await k.raw('CREATE TABLE proven_txs(provenTxId INTEGER,other INTEGER,PRIMARY KEY(provenTxId,other))') + await k.raw('DROP TABLE transactions') + await expect(validateSqliteSource(k)).rejects.toThrow('Unsupported snapshot global source key') + await expect(k.transaction(t => validateSqliteSource(t))).rejects.toThrow('Unsupported snapshot global source key') + } finally { + await k.destroy() + } +}) + +test('auxiliary validation retains the first definition rejection before a later missing table', async () => { + const k = await minimalGlobalDatabase() + try { + await addSnapshotGlobalIndexes(k) + const definitions = tables() + await k.raw('ALTER TABLE ?? ADD COLUMN extra INTEGER', [definitions[0].name]) + await k.schema.dropTable(definitions[1].name) + expect(await sqliteTable(k, definitions[0], true)).toBe(false) + await expect(validateSqliteTables(k, definitions)).rejects.toThrow('Snapshot global table definition mismatch') + await expect(k.transaction(t => validateSqliteTables(t, definitions))).rejects.toThrow( + 'Snapshot global table definition mismatch' + ) + } finally { + await k.destroy() + } +}) + +async function indexDetailQueries(k: Knex, run: () => Promise): Promise { + const values: string[] = [] + const listener = (query: { sql: string }) => values.push(query.sql) + k.on('query', listener) + try { + await run() + return values.filter(sql => sql.includes('index_xinfo')) + } finally { + k.off('query', listener) + } +} + +test('pinned auxiliary validation groups fresh index parts while preserving the individual table oracle', async () => { + const k = await minimalGlobalDatabase() + try { + await addSnapshotGlobalIndexes(k) + await k.transaction(async t => { + const definitions = tables() + const original = await indexDetailQueries(t, async () => { + await runInSeries(definitions, async table => { + expect(await sqliteTable(t, table, true)).toBe(true) + }) + }) + const grouped = await indexDetailQueries(t, () => validateSqliteTables(t, definitions)) + expect(grouped.length).toBeLessThan(original.length) + expect(grouped.every(sql => sql.includes('pragma_index_xinfo'))).toBe(true) + expect(await indexDetailQueries(t, () => validateSqliteTables(t, definitions))).toEqual(grouped) + }) + } finally { + await k.destroy() + } +}) + +test('fresh index-part validation keeps descending-index rejection and observes rollback', async () => { + const k = await minimalGlobalDatabase() + try { + await addSnapshotGlobalIndexes(k) + const definitions = tables() + const table = definitions.find(value => value.indexes.length > 0)! + const index = table.indexes[0] + await expect( + k.transaction(async t => { + await t.raw('DROP INDEX ??', [index.name]) + await t.raw(`CREATE INDEX ?? ON ?? (${index.columns.map(() => '?? DESC').join(',')})`, [ + index.name, + table.name, + ...index.columns + ]) + expect(await sqliteTable(t, table, true)).toBe(false) + await expect(validateSqliteTables(t, definitions)).rejects.toThrow('Snapshot global table definition mismatch') + throw new Error('owned index-part rollback') + }) + ).rejects.toThrow('owned index-part rollback') + await k.transaction(t => validateSqliteTables(t, definitions)) + expect(await sqliteTable(k, table, true)).toBe(true) + } finally { + await k.destroy() + } +}) + +test('partial required indexes keep the original rejection without accepting grouped parts', async () => { + const k = await minimalGlobalDatabase() + try { + await addSnapshotGlobalIndexes(k) + const table = tables().find(value => value.indexes.length > 0)! + const index = table.indexes[0] + await k.transaction(async t => { + await t.raw('DROP INDEX ??', [index.name]) + await t.raw(`CREATE INDEX ?? ON ?? (${index.columns.map(() => '??').join(',')}) WHERE ?? >= 0`, [ + index.name, + table.name, + ...index.columns, + index.columns[0] + ]) + const seen: string[] = [] + const listener = (query: { sql: string }) => seen.push(query.sql) + t.on('query', listener) + try { + await expect(validateSqliteTables(t, [table])).rejects.toThrow('Snapshot global table definition mismatch') + } finally { + t.off('query', listener) + } + expect(seen.some(sql => sql.includes('pragma_index_xinfo'))).toBe(false) + expect(await sqliteTable(t, table, true)).toBe(false) + }) + } finally { + await k.destroy() + } +}) + +test('outside pins and the original sqlite3 selection keep individual index-part statements', async () => { + const k = await minimalGlobalDatabase() + try { + await addSnapshotGlobalIndexes(k) + const definitions = tables() + const unpinned = await indexDetailQueries(k, () => validateSqliteTables(k, definitions)) + expect(unpinned.every(sql => sql.startsWith('PRAGMA index_xinfo'))).toBe(true) + await k.transaction(async t => { + const client = t.client.config.client + try { + t.client.config.client = 'sqlite3' + expect(await indexDetailQueries(t, () => validateSqliteTables(t, definitions))).toEqual(unpinned) + } finally { + t.client.config.client = client + } + }) + } finally { + await k.destroy() + } +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteSchemaObservations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteSchemaObservations.ts new file mode 100644 index 000000000..bc73778d4 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/schema/snapshotSqliteSchemaObservations.ts @@ -0,0 +1,54 @@ +import type { Knex } from 'knex' + +interface SchemaColumn { + cid: number + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + hidden: number +} +interface SchemaIndex { + seq: number + name: string + unique: number + origin: string + partial: number +} +export interface SqliteSchemaObservation { + table: string + columns: SchemaColumn[] + indexes: SchemaIndex[] +} + +/** Fresh metadata within the caller's pinned SQLite transaction only. Outside + * that transaction, preserve the original sequential individual reads. */ +export async function readSqliteSchemaObservations( + k: Knex, + names: string[] +): Promise { + const tables = [...names] + if ( + !k.isTransaction || + k.client.config.client !== 'better-sqlite3' || + tables.length === 0 || + tables.length > 16 || + !tables.every(table => typeof table === 'string' && /^[a-z_][a-z0-9_]*$/i.test(table)) + ) + return undefined + const selected = tables.map((_, index) => `SELECT ${index} ordinal, ? name`).join(' UNION ALL ') + const columns: Array = await k.raw( + `SELECT CAST(s.ordinal AS TEXT) sourceOrdinal,p.* FROM (${selected}) s CROSS JOIN pragma_table_xinfo(s.name) p ORDER BY s.ordinal,p.cid`, + tables + ) + const indexes: Array = await k.raw( + `SELECT CAST(s.ordinal AS TEXT) sourceOrdinal,p.* FROM (${selected}) s CROSS JOIN pragma_index_list(s.name) p ORDER BY s.ordinal,p.seq`, + tables + ) + const observed: SqliteSchemaObservation[] = tables.map(table => ({ table, columns: [], indexes: [] })) + const byOrdinal = new Map(observed.map((value, index) => [String(index), value])) + for (const { sourceOrdinal, ...column } of columns) byOrdinal.get(sourceOrdinal)?.columns.push(column) + for (const { sourceOrdinal, ...index } of indexes) byOrdinal.get(sourceOrdinal)?.indexes.push(index) + return observed +} diff --git a/scripts/mutation-partitions.mjs b/scripts/mutation-partitions.mjs index c82ec3aea..70acc2fdc 100644 --- a/scripts/mutation-partitions.mjs +++ b/scripts/mutation-partitions.mjs @@ -40,6 +40,7 @@ const plans = new Map([ ['src/storage/schema/snapshotGlobalIndexModel.ts', 'global-index'], ['src/storage/schema/snapshotGlobalIndexMysql.ts', 'global-mysql'], ['src/storage/schema/snapshotGlobalIndexSqlite.ts', 'global-sqlite'], + ['src/storage/schema/snapshotSqliteSchemaObservations.ts', 'global-sqlite'], ['src/storage/schema/snapshotGlobalIndexBootstrap.ts', 'global-bootstrap'], ['src/storage/schema/snapshotGlobalIndexTriggers.ts', 'global-triggers'], ['src/storage/schema/snapshotSqliteIdentity.ts', 'sqlite-identity'], From 1d9e3481953a5f4276a243aabbe5821c614daba0 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 5 Oct 2026 18:53:55 -0700 Subject: [PATCH 123/127] fix(deps): remediate proxy-addr and source-map-js advisories --- docs/reference/dependency-policy.md | 46 ++++++++++++++++--- infra/chaintracks-server/package-lock.json | 10 ++-- infra/message-box-server/package-lock.json | 10 ++-- infra/uhrp-server-basic/package-lock.json | 11 +++-- .../package-lock.json | 10 ++-- infra/wab/package-lock.json | 11 +++-- infra/wallet-infra/package-lock.json | 10 ++-- pnpm-lock.yaml | 18 ++++---- 8 files changed, 92 insertions(+), 34 deletions(-) diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index 0b1191734..e3a9be0f2 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -3,8 +3,8 @@ id: dependency-release-policy title: 'Dependency and Release Policy' kind: reference version: '1.3.4' -last_updated: '2026-10-02' -last_verified: '2026-10-02' +last_updated: '2026-10-06' +last_verified: '2026-10-06' review_cadence_days: 30 status: stable tags: [reference, dependencies, security, releases] @@ -103,10 +103,10 @@ runtime/deployment effects, remove obsolete dependencies, and require the same tests, security analysis, and package checks as human-authored work. Bot noise, conflicting single-package bumps, and first-party version PRs are consolidated or closed rather than merged piecemeal. CI recognizes dependency-shaped diffs -and requires the pull request's dependency-evidence section to record release -notes and necessity, runtime/build/peer compatibility, lockfile deduplication, -audit and CodeQL results, package and consumer tests, bundle/performance -impact, and affected public versions. +and reports advisory evidence for release notes and necessity, runtime/build/peer +compatibility, lockfile deduplication, audit and CodeQL results, package and +consumer tests, bundle/performance impact, and affected public versions. Missing +fields produce a warning; they do not waive any security or merge gate. The docs-site Mermaid graph selects DOMPurify 3.4.16 instead of 3.4.13 for [GHSA-p98j-92pf-mc4p](https://github.com/advisories/GHSA-p98j-92pf-mc4p). @@ -120,6 +120,37 @@ public APIs or candidate versions. Frozen installation, root checks, docs tests and a built-site browser check qualify the ordinary Mermaid consumer. No service or package is deployed by this source change. +## October 6 compatible audit remediation + +The October 5 advisory database update identified the existing locked +`proxy-addr` 2.0.7 and `source-map-js` 1.2.1 resolutions. The workspace now +selects the upstream patched +[proxy-addr 2.0.8](https://github.com/advisories/GHSA-jqcg-44mw-7w3h) and +[source-map-js 1.2.2](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) +within their parents' existing ranges. The six affected standalone server +locks also select proxy-addr 2.0.8; the Overlay lock already selected it. +Package manifests, public APIs, peers, overrides and unrelated resolutions +remain unchanged. Both patches retain their existing Node runtime floors and +dependency graph; source-map-js still has no runtime dependencies. + +The proxy-addr patch was published September 15. The source-map-js patch was +published September 30 and was about 131 hours old at review: older than the +governed 24-hour floor, younger than the automatic seven-day delay for +ordinary updates. Its targeted security resolution used +`pnpm --config.minimumReleaseAge=1440 --recursive update --depth Infinity +--lockfile-only --ignore-scripts --no-save source-map-js`. This is an explicit +security selection, with the ordinary workspace default, provenance policy +and lifecycle denial preserved. No permanent age exclusion, override or +advisory dismissal was added. pnpm generated the workspace lock, and +`npm update proxy-addr --package-lock-only --ignore-scripts --audit=false +--fund=false --workspaces=false` generated each affected standalone lock. + +The coherent batch must pass frozen installs, security audits, complete root +checks, affected consumers and the exact-head hosted gate before review. +Lockfile remediation does not publish a package or deploy a service. Protected +publication and normal source-owned availability, provenance and rollback +checks remain separate requirements for any later service rollout. + ## Temporary Metro watcher dependency repair Metro-file-map 0.87.1 uses only micromatch.some(), whose matcher is already @@ -169,7 +200,8 @@ startup or persisted schema; deployed images require separate promotion. - dependency build scripts are denied unless explicitly listed in `allowBuilds`; - peer dependencies must be declared explicitly instead of being installed implicitly (including unused optional tooling peers); -- ordinary releases must age for 24 hours before installation; +- ordinary releases wait seven days before automatic installation; the governed + inventory floor remains 24 hours, including explicitly reviewed security updates; - first-party `@bsv/*` packages are exempt so coordinated releases can complete; - registry provenance downgrades are rejected for recent packages; and - `pnpm audit --audit-level=high` blocks high and critical advisories in CI and diff --git a/infra/chaintracks-server/package-lock.json b/infra/chaintracks-server/package-lock.json index 2d7e162ba..ad3ef1030 100644 --- a/infra/chaintracks-server/package-lock.json +++ b/infra/chaintracks-server/package-lock.json @@ -4000,9 +4000,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -4010,6 +4010,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { diff --git a/infra/message-box-server/package-lock.json b/infra/message-box-server/package-lock.json index a4169da27..85da65a4f 100644 --- a/infra/message-box-server/package-lock.json +++ b/infra/message-box-server/package-lock.json @@ -10104,9 +10104,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -10114,6 +10114,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { diff --git a/infra/uhrp-server-basic/package-lock.json b/infra/uhrp-server-basic/package-lock.json index 7084da903..53cac423c 100644 --- a/infra/uhrp-server-basic/package-lock.json +++ b/infra/uhrp-server-basic/package-lock.json @@ -7771,15 +7771,20 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { diff --git a/infra/uhrp-server-cloud-bucket/package-lock.json b/infra/uhrp-server-cloud-bucket/package-lock.json index ef8716687..506e9f200 100644 --- a/infra/uhrp-server-cloud-bucket/package-lock.json +++ b/infra/uhrp-server-cloud-bucket/package-lock.json @@ -8749,9 +8749,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -8759,6 +8759,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { diff --git a/infra/wab/package-lock.json b/infra/wab/package-lock.json index e27f4b526..2fec205fd 100644 --- a/infra/wab/package-lock.json +++ b/infra/wab/package-lock.json @@ -8710,15 +8710,20 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { diff --git a/infra/wallet-infra/package-lock.json b/infra/wallet-infra/package-lock.json index 8e1780bbb..a06854e4a 100644 --- a/infra/wallet-infra/package-lock.json +++ b/infra/wallet-infra/package-lock.json @@ -4052,9 +4052,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -4062,6 +4062,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01eebae25..ee9eb6923 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7758,8 +7758,8 @@ packages: protons-runtime@7.1.1: resolution: {integrity: sha512-c/PYyiENwXzz+JQDISCDfBFAxmASuDJfoUovBUa81WJ0F6RdEy/u6wHvLIgmrzyl+q3ulvVLTHNCErHs4X3cjA==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} publint@0.3.24: @@ -8152,8 +8152,8 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.21: @@ -13373,7 +13373,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 @@ -14627,7 +14627,7 @@ snapshots: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 main-event@1.0.5: {} @@ -15767,7 +15767,7 @@ snapshots: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres-array@2.0.0: {} @@ -15847,7 +15847,7 @@ snapshots: uint8arraylist: 3.0.2 uint8arrays: 6.1.1 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -16402,7 +16402,7 @@ snapshots: - supports-color - utf-8-validate - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map-support@0.5.21: dependencies: From 32d7d5698e2fb33ed37218e4360a8610980c4e96 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 5 Oct 2026 21:10:56 -0700 Subject: [PATCH 124/127] fix(wallet): keep all property suites in the test command --- docs/reference/package-api-migrations.md | 74 ++++++++++----------- governance/package-release-notes.json | 4 +- packages/wallet/wallet-toolbox/README.md | 6 ++ packages/wallet/wallet-toolbox/package.json | 2 +- scripts/test-governance.test.mjs | 23 +++++++ 5 files changed, 69 insertions(+), 40 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 2a04480c4..f07c77719 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,41 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | -| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | -| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay` | `2.6.3` | `2.6.4` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. For UMP lineage, deploy this engine patch together with overlay-topics 2.0.1 or an equivalent custom UMP history decider. Existing selector decisions and resource bounds remain unchanged. | -| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/overlay-topics` | `2.0.0` | `2.0.1` | patch | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). The prior 2.0.0 contracts above are the published baseline. This 2.0.1 UMP patch has no API, wire or schema migration; deploy with overlay 2.6.4 to retain history past confirmation. | -| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/sdk` | `2.8.10` | `3.1.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. The additive authorization callback requires independently verified local policy; wallet-returned labels or fields are not authority. The completeBoundAction.outputAuthorizationVersion function property allows compatible consumers to detect support without importing a missing named export from older ESM peers. No BRC100 wire or persistence migration is introduced by this addition. | -| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | -| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | -| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | -| `@bsv/wallet-toolbox` | `2.14.5` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. | -| `@bsv/wallet-toolbox-client` | `2.14.5` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. | -| `@bsv/wallet-toolbox-mobile` | `2.14.5` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. | -| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.3.2` | `0.3.3` | patch | [API and usage](../packages/middleware/402-pay.md) | No migration. Continue using the existing BRC-121 headers and replay store. Select SDK AuthFetch with auth/payment Express middleware for BRC-118; this package does not implicitly negotiate that protocol. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/air-gap` | `0.1.3` | `0.1.3` | none | [API and usage](../packages/helpers/air-gap.md) | No migration is required for valid BRC-141 v1 senders or decoders. Malformed, oversized, duplicate-heavy, or adversarial high-degree sessions now fail closed or are evicted within the documented limits instead of consuming unbounded resources. | +| `@bsv/amountinator` | `2.1.6` | `2.1.7` | patch | [API and usage](../packages/helpers/amountinator.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth` | `0.1.5` | `0.1.6` | patch | [API and usage](../packages/middleware/auth.md) | No valid proof bytes or public API shape changes. Client and server must use the same protocol tuple; security levels 0, 1, and 2 remain supported and select wallet consent policy while the explicit counterparty scopes derivation at every level. Proof and wallet adapters must supply plain own data fields and dense byte arrays; inherited, accessor-backed, sparse, or malformed runtime shapes fail closed. Structured bodies containing non-finite numbers or negative zero must be normalized to an unambiguous wire representation before signing. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/auth-express-middleware` | `2.2.8` | `2.3.0` | minor | [API and usage](../packages/middleware/auth-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set captureRawBody:true before body parsers when enabling multipart payment middleware. Default parsed-body integration remains available; nonempty non-multipart preimages remain compatible. Raw collection rejects compressed, oversized, slow and already-consumed bodies and enforces aggregate memory and pending-request bounds. Multipart boundary parameters must be signed exactly; receivers do not fall back to verification without the boundary. Configure public credential-free CORS preflight and expose the negotiation header. No deployment or publication is performed by this source candidate. For empty binary requests, upgrade SDK clients to the integrated 3.0.0 candidate and auth receivers to 2.3.0 together; older length-0 signatures/reconstructions need the paired correction. Nonempty preimages are unchanged. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket` | `2.1.8` | `2.1.9` | patch | [API and usage](../packages/messaging/authsocket.md) | No API or wire migration is required for valid JSON events. Existing numeric-key objects under byte-like names are unchanged; typed payment protocols recover historical byte objects at their explicit fields. Outbound non-JSON or ambiguous runtime values, including negative zero, nested undefined, sparse arrays, accessors, hidden/extra properties, and serialization hooks, now fail closed before signing. requestedCertificates is an SDK allowlist, not an application authorization verdict. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/authsocket-client` | `2.1.7` | `2.1.8` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API or event-wire migration. ESM/CommonJS consumers retain their existing SDK peer range. Browser distributors adopt the new UMD artifact after publication and must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. This source candidate does not publish or update current wallet release pins. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms` | `1.2.3` | `1.2.4` | patch | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. Valid canonical token amounts remain compatible. Audit historical tokens for signed, exponent, leading-zero, non-positive, or greater-than-9007199254740991 amount fields before rebuilding wallet state; public number-based operations now fail closed when an aggregate cannot be represented exactly. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/btms-permission-module` | `1.2.1` | `1.2.2` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | Valid canonical BTMS flows remain compatible. Custom prompts must return literal true, authorization maps may contain at most 1,024 entries, and callers must provide dense signatures, safe exact totals, and canonical preimages; coercive or trailing-byte forms now fail closed. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/chirp` | `0.1.3` | `0.1.4` | patch | [API and usage](../packages/network/chirp.md) | No API, codec, object identifier, service schema, or wire-format migration. Use @bsv/chirp 0.1.3 with SDK 2.8.9 or later and Cloud Bucket 0.2.48 or Lite 0.1.45 for authenticated binary uploads, including full 4 MiB chunks. Custom authenticated transports must send the supplied owned byte body unchanged and supply ordinary HTTP framing; Content-Type remains signed through AuthFetch. Earlier service entrypoints do not parse staged binary bytes before authentication and require this service patch. Existing stored objects, sessions, advertisements and leases remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/did` | `0.2.6` | `0.3.0` | minor | [API and usage](../packages/helpers/did.md) | DID0.3 is a breaking pre-1.0 candidate: compressed identity-key input only; remote document/lifecycle options rejected. Use original signed BRC52 binary for envelopes, or explicitly verified compatible structured core. Wire authenticated BRC103/104 payload/control, durable atomic replay, consent, issuer/schema/purpose reliance and local status policy; callbacks alone are not full transport conformance. See docs/guides/identity-did-vc-migration.md. Proposed custom W3C mechanisms remain unregistered. Import BRC52 APIs/types from @bsv/did/brc52; identity-key DID and independent SD-JWT helpers stay at @bsv/did. | +| `@bsv/ecpm-permission-module` | `0.1.1` | `0.1.2` | patch | [API and usage](../packages/wallet/ecpm-permission-module.md) | Valid ecpm requests remain compatible. Authorization callbacks must return literal true; inherited, accessor-backed, malformed, or oversized inputs now fail closed. Hosts must keep grants below 1,024 entries and avoid more than 64 concurrently pending new authorization prompts per module instance. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/fund-wallet` | `1.5.2` | `1.5.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/gasp` | `1.3.7` | `1.3.8` | patch | [API and usage](../packages/overlays/gasp.md) | No API or wire migration is required. In bidirectional mode, the receiver must already have the parent of a pushed child, request it during a subsequent synchronization round, or reject the graph because GASP v1 submitNode does not transmit the spentBy parent outpoint. Pull-only operation avoids that assumption. Custom storage and remote adapters must preserve exact request/node binding and bounded responses. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/lch` | `0.2.0` | `0.2.1` | patch | [API and usage](../packages/content/lch.md) | Replace buyer.quote(endpoint, request, issuer, keyGrants) with buyer.quote(verifiedSignedOffer, request, expectedSeller, keyGrants). JavaScript callers that still pass an endpoint string fail before transport I/O. Persist the returned plan and every partial settlement proof, retry with the same funded transaction, and configure a profile-aware agreementEvaluator before completion or recovery. Low-level unverified recovery results must not authorize key storage or content access. DNS endpoints require an address-pinning connector outside trusted browser environments. Distributors must retain THIRD_PARTY_NOTICES.md with the package; published BRC-170 remains authoritative if the implementation and standard differ. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/message-box-client` | `2.5.4` | `2.6.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing sender byte-array encoding and receiver byte forms remain compatible. Base64 must use the canonical standard alphabet and padding and decode to no more than 64 MiB. This receiver extension does not raise relay/intermediary limits or provide a durable refund/result journal; #548 and #503 retain those remaining milestones. Adopt the integrated SDK 3.0.0 candidate to obtain the independently tested BRC-29 recipient-key correction. No migration required for the new fields: the PeerMessage paymentOutcome and retained payment are optional, and messages without a payment are unchanged. To avoid losing payments, store any returned payment (validate it first; it is the raw envelope value) before acknowledging its message. Install @bsv/sdk 2.8.6 or later alongside this package; payments refused on earlier SDKs remain pending in the message box and can be accepted after upgrading. No BRC100 wire or wallet-data change. SDK consumers may retain ^2.8.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay` | `2.6.3` | `2.6.4` | patch | [API and usage](../packages/overlays/overlay.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. For UMP lineage, deploy this engine patch together with overlay-topics 2.0.1 or an equivalent custom UMP history decider. Existing selector decisions and resource bounds remain unchanged. | +| `@bsv/overlay-discovery-services` | `2.2.6` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-express` | `2.7.3` | `2.7.4` | patch | [API and usage](../packages/overlays/overlay-express.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.4.0 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/overlay-topics` | `2.0.0` | `2.0.1` | patch | [API and usage](../packages/overlays/overlay-topics.md) | Topics2 is a breaking retirement candidate. Remove DIDTopicManager/createDIDLookupService/DIDRecord/DIDQuery and tm_did/ls_did usage. Resolve immutable identity-key DIDs offline; explicitly configured tm_identity/ls_identity supplies certificate discovery, not DID-document mutation or automatic trust. Preserve existing database/history and plan service rollout without automatic replacement installation. See docs/guides/identity-did-vc-migration.md. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. Mandala is a clean break with no data migration: the old MandalaToken/MandalaAdmin wire format is no longer admitted and 1.x Mandala rows are not read or converted, so start Mandala on a new database with new deploys (existing on-chain outputs are not spent or deleted). MandalaTopicManager drops adminWallet, adminProtocolID and stateStore.isAdminOutpoint and requires trustedIssuers (non-empty compressed lowercase public keys) and engineOutputs, with optional membership, membershipExempt and onOwnerRepair; pass the same MandalaStorageManager (a MandalaStateStore) to admission and lookup. Refusals are MandalaReject { code, reason } from the Reasons catalog; do not match on old error text. MandalaLinkagePayload is replaced by MandalaEnvelope with encodeEnvelope/decodeEnvelope; the register action is replaced by a deploy at output 0 with a deploySig. foldAction, defaultAssetState, AssetAdminState, FoldContext and MandalaTokenRecord use tokenId (_0) instead of assetId, drop issuerIdentityKey and add feeRatePerKb. Persisted schema (section 6.6): new mandalaOwners and mandalaAuthorities collections; mandalaTokens, mandalaMetadata, mandalaAssetStates and mandalaAdminHistory are keyed by tokenId, metadata holds the decoded deploy payload, and history rows store kind, detailsHex, commitment and delta (plus optional frozenAmount/frozenOwner on freezes). De-trusting an issuer key revokes the authority coins it holds; rotate keys by moving authority coins first. Operators register tm_mandala_registry/ls_mandala_registry explicitly. The infra/overlay-server wiring follows after publication (P1b). The prior 2.0.0 contracts above are the published baseline. This 2.0.1 UMP patch has no API, wire or schema migration; deploy with overlay 2.6.4 to retain history past confirmation. | +| `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/payment-express-middleware` | `2.1.8` | `2.2.0` | minor | [API and usage](../packages/middleware/payment-express-middleware.md) | Requires @bsv/sdk ^2.9.0 \|\| ^3.0.0. Set enableMultipart:true behind @bsv/auth-express-middleware captureRawBody:true installed before parsers. Support is advertised only on verified raw-byte requests. Configure maxPaymentBytes and maxPaymentBodyBytes with matching proxy/auth limits, and share a durable replay store across processes. Preserve recovery context after any submitted payment: the released ERR_PAYMENT_FAILED 400 does not prove rollback, and replay-store 503 is also ambiguous. Header-only deployments remain supported. See the BRC-118 guide. The retained SDK2 alternative starts at ^2.9.0; the integrated source graph now uses SDK3, including the paired BRC-118 correction. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/sdk` | `2.8.10` | `3.1.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Use a BRC-100 wallet that honors noSend and sendWith and implements abortAction for automatic payments. Deploy raw-byte auth and multipart payment receivers first. Header-only or GET/HEAD requests whose payments require multipart fail before broadcast; select an application-supported body-bearing route explicitly. Serialize file-bearing FormData to owned bytes and retain its exact Content-Type; text-only FormData retains URL encoding. Reconcile submitted/uncertain payments before creating another spend, including after changed requirements, cancellation or proxy 413/431. Existing small header payments and nonempty non-multipart signing retain their wire format. Empty byte bodies use the BRC-104 -1 sentinel; clients and auth receivers that signed/reconstructed length 0 must adopt SDK 2.9.0 and auth middleware 2.3.0 together for those requests. BRC-29 receivers now derive their own child key. See the BRC-118 guide for limits and known external-peer differences. This candidate is not published and does not change current wallet-release pins. The included 2.8.3 discovery/browser-fetch and signed action-history repairs preserve the existing BRC100 API, historical wire bytes and account data. Older compatible clients retain their calls; applications bundling the affected 2.8.x binary client need the signed-history repair as well as the wallet. No ecosystem-wide migration is required for those compatibility fixes. SDK 2.8.3 was published by the protected SDK-only release workflow with verified registry integrity and provenance. The included 2.8.11 certificate-issuance compatibility repair requires no API, wire or wallet-data migration. SDK3 is a separate breaking retirement candidate: remove identity/DIDTokenValidation imports and use @bsv/did BRC202 identity-key resolution plus optional @bsv/did/brc52 original-signature envelopes. Retain historical data and recover authenticated identity bindings; do not relabel serial DIDs. Existing acquisition-result validation fixes remain. First-party SDK3 peer alternatives require complete packed qualification and owner coordination; preserved SDK2 ranges are not silently narrowed. See docs/guides/identity-did-vc-migration.md. The additive authorization callback requires independently verified local policy; wallet-returned labels or fields are not authority. The completeBoundAction.outputAuthorizationVersion function property allows compatible consumers to detect support without importing a missing named export from older ESM peers. No BRC100 wire or persistence migration is introduced by this addition. | +| `@bsv/simple` | `0.6.0` | `0.7.0` | minor | [API and usage](../packages/helpers/simple.md) | Simple0.7 is a breaking pre-1.0 candidate. Remove mutable DID/resolver/provider configuration, proxy handlers and DIDError/types; use DID.fromIdentityKey/resolve and wallet.getDID. CredentialIssuer.issue returns {credential,keyringForSubject}; verify accepts exact JSON or UTF8 bytes and returns structured cryptographic results. The HTTP verify route now requires {credential: originalEnvelopeJson} with original text as a string; parsed objects return HTTP400. Web request decoding rejects duplicate members and invalid UTF8, and custom parsed-body adapters must establish equivalent strict decoding. Preserve received text and inspect verification.verified, not HTTP success. Local revocation record status is retained/unknown, not inferred chain revocation. See docs/guides/identity-did-vc-migration.md. Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New CredentialSchema and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. Persisted local field ordering requires no migration; transport adapters preserve original certificate signing bytes. | +| `@bsv/templates` | `1.10.2` | `2.0.0` | major | [API and usage](../packages/helpers/templates.md) | Breaking. Removed exports: MandalaToken, MandalaTokenDecoded, MandalaAdmin, ADMIN_PROTOCOL, MandalaAdminDecoded, MandalaActionDetails, MandalaActionKind, MandalaAdminLockParams, MandalaAdminUnlockParams and AssetMetadata. Removed subpath @bsv/templates/mandala-signing.ts. The @bsv/templates/mandala-encoding.ts subpath keeps only createMinimallyEncodedScriptChunk, decodeScriptNum and decodeScriptNumChunk; encodeScriptNum, encodeAssetId and decodeAssetId are removed. Replace MandalaToken/MandalaAdmin with Bsv21Binary: deploy, authority and value outputs are lock(tokenId \| null, amount, pubKeyHash, payload?) with bigint amounts and _0 token ids (tokenIdFromString/tokenIdToString), decoded with Bsv21Binary.decode. Mandala admin actions are committed by sha256 of strict-CBOR details in the authority output payload, not by a commitment-derived key. This is a clean break with no data migration: tokens and admin chains in the old format are not readable by 2.0.0. @bsv/overlay-topics 2.0.0 consumes this format; the infra/overlay-server wiring follows after publication. SDK consumers may retain ^2.1.6 or install the SDK3 candidate; SDK3 removes the obsolete DID token API (see docs/guides/identity-did-vc-migration.md). | +| `@bsv/teranode-listener` | `1.1.6` | `1.1.7` | patch | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/verifast` | `0.3.6` | `0.3.7` | patch | [API and usage](../packages/sdk/verifast.md) | Valid typed verification calls and worker protocols remain compatible. Custom module factories and WASM adapters must return the exact documented binary and boolean shapes; coercive network, height, flag, byte, batch, verdict, lifecycle, or disposed-instance values now fail closed. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. SDK consumers may retain ^2.1.8 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-helper` | `0.1.8` | `0.1.9` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No API migration. This unpublished candidate resolves the next source graph; adopt only after its dependencies are published. Existing wallet releases retain their current published pins. SDK consumers may retain ^2.1.6 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-relay` | `0.5.1` | `0.5.2` | patch | [API and usage](../packages/wallet/wallet-relay.md) | Enable signed QR codes and distribute only HTTPS pairing origins and relay API URLs. Root-relative API paths and loopback HTTP remain supported. Configure onApprovalRequired for every method not deliberately listed in autoApproveMethods; unsigned pairing URIs and implicit approval are no longer accepted. Existing relay sessions, custom RPC method names, and supported wallet RPC byte encodings remain valid, and host applications continue to provide their matching Express runtime and type graph. Version 0.5 defaults to two missed pongs; set maxMissedHeartbeats to 1 to retain the prior heartbeat policy. Heartbeat intervals must fit the Node timer range. Preserve Cache-Control: no-store at proxies, prefer the stable bsv-wallet-relay plus token WebSocket subprotocols over the legacy token query parameter, and configure edge rate limiting in addition to the bounded in-process defaults. The 24-hour connected-session lifetime no longer renews on reconnect, malformed wallet calls fail with code 400, and WalletRelayClient retains at most 100 log entries unless maxLogEntries is configured. The QR remains a short-lived bearer invitation: keep it private and require explicit operation approval. Relay envelopes, cryptographic framing, and wallet RPC encodings remain unchanged. SDK consumers may retain ^2.4.1 or install the proposed SDK3 candidate. The immutable SDK2.8.11 reference has nine pre-existing SDK cold-leaf/UMD import defects; all historical floor versions are not qualified by this matrix. SDK3 removes the obsolete DID token API; use the identity-key DID and optional BRC52 adapters described in docs/guides/identity-did-vc-migration.md. No other public API or wire migration is introduced by the peer alternative. | +| `@bsv/wallet-toolbox` | `2.14.5` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. No additional API, storage or runtime migration is needed for this test-command correction; run the existing test:property command to include its complete declared suite list. | +| `@bsv/wallet-toolbox-client` | `2.14.5` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use the additive syncFromReaderResumable API and its progress/cancellation contracts; custom providers keep the exclusive fallback until they implement the capability contract. No IndexedDB schema migration is required. Source timestamps remain inclusive rather than a coherent snapshot; equal-time boundary rows can be reread. Select explicit page bytes for constrained devices. Publication and downstream wallet adoption are separate actions. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. | +| `@bsv/wallet-toolbox-mobile` | `2.14.5` | `2.15.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. The additive syncFromReaderResumable API uses atomic local pages only for capable local providers; remote/custom destinations keep the serialized fallback. Existing mobile runtime/crypto backend registration and RPC wires remain unchanged. The original legacy page subset needs no migration. Reconcile cancellation and lost acknowledgements using the durable checkpoint; do not infer coherent source snapshots. Current wallet releases are not required to consume this candidate. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Do not assume retained-view support from the types; inspect the concrete local provider capability. Existing scoped capture remains unchanged. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Backend-bound orphan recovery remains required before reader advertisement. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. | +| `create-bsv-app` | `1.1.2` | `1.1.2` | none | [API and usage](../packages/helpers/create-bsv-app.md) | Existing CLI flags, network choices, and generated project structure are unchanged. Regenerate or update dependencies after the patched packages are published. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -514,8 +514,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. Global-index validation groups fresh bounded table and index metadata inside pinned SQLite transactions while preserving individual driver/oversized/unpinned fallbacks, public validator arity, partial/descending-index rejection and EXPLAIN checks. Fixes WAB faucet redemption for an empty wallet when storage adds a service charge. Local signer decisions for independently validated commission and derived change are retained privately through the permissions wrapper and bound by the SDK before signing. UMP pins anchor verified token-update lineage: password and token updates supersede their pinned predecessor, while unrelated historical continuations cannot override the anchor. Explicit overlay history is linked past confirmed Merkle anchors. Updates with missing pin ancestry remain indeterminate instead of selecting an unrelated continuation. Every retained token spend proves control through its unlocking script, including confirmed updates and hash rotation; funding paths do not establish token lineage. -- Migration: These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. +- Release note: Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main’s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. Global-index validation groups fresh bounded table and index metadata inside pinned SQLite transactions while preserving individual driver/oversized/unpinned fallbacks, public validator arity, partial/descending-index rejection and EXPLAIN checks. Fixes WAB faucet redemption for an empty wallet when storage adds a service charge. Local signer decisions for independently validated commission and derived change are retained privately through the permissions wrapper and bound by the SDK before signing. UMP pins anchor verified token-update lineage: password and token updates supersede their pinned predecessor, while unrelated historical continuations cannot override the anchor. Explicit overlay history is linked past confirmed Merkle anchors. Updates with missing pin ancestry remain indeterminate instead of selecting an unrelated continuation. Every retained token spend proves control through its unlocking script, including confirmed updates and hash rotation; funding paths do not establish token lineage. Corrects the property command argument order so all registered portable BRC-38/39 suites execute rather than becoming Jest ignore patterns, with an actual-parser governance regression. +- Migration: These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. No additional API, storage or runtime migration is needed for this test-command correction; run the existing test:property command to include its complete declared suite list. | Public subpath | Runtime target(s) | Declaration target(s) | | ----------------- | -------------------------------------------------------------------------------- | ---------------------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 1313880ba..a527fe0fb 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -210,8 +210,8 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.14.5", "releaseType": "minor", - "summary": "Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. Global-index validation groups fresh bounded table and index metadata inside pinned SQLite transactions while preserving individual driver/oversized/unpinned fallbacks, public validator arity, partial/descending-index rejection and EXPLAIN checks. Fixes WAB faucet redemption for an empty wallet when storage adds a service charge. Local signer decisions for independently validated commission and derived change are retained privately through the permissions wrapper and bound by the SDK before signing. UMP pins anchor verified token-update lineage: password and token updates supersede their pinned predecessor, while unrelated historical continuations cannot override the anchor. Explicit overlay history is linked past confirmed Merkle anchors. Updates with missing pin ancestry remain indeterminate instead of selecting an unrelated continuation. Every retained token spend proves control through its unlocking script, including confirmed updates and hash rotation; funding paths do not establish token lineage.", - "migration": "These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite." + "summary": "Reads fresh bounded SQLite identity and journal-observer metadata for each operation, groups metadata in one pass, and validates exact installed definitions in pages of at most sixteen. Preserves original metadata, DDL and validation/error order without a schema or authority cache. Adds internal provider-owned journal floor/collection maintenance with complete generation validation, configured migration-owner exclusion, bounded cancellation/result lifetime and physical cleanup admission fencing. Adds internal monotonic continuity-floor transactions and primary-key tombstone collection bounded by examined records, with live-receipt pins and native WAL/RC/RR partial-delete/commit recovery proof. Adds bounded exact source-prefix receipts and explicit immutable receipt policy to owned SQLite/MySQL journal generations, with nonwaiting current reads, bounded expiry collection and process-loss recovery checks. Adds indexed exact SQLite journal source probes and an explicit durable bootstrap row allowance with atomic cursor/charge recovery and source-preserving exhaustion. Adds internal unadvertised SQL journal primitives and source-bound crash-resumable generation ownership, retaining bounded exact-revision pages and bootstrap. Accepts valid terminal all-zero checkpoint resets on unchanged backups while preserving state binding, timestamp monotonicity and nonterminal/partial-reset validation. Adds capability-gated resumable local sync with atomic page/checkpoint commits, cancellation, progress timings, fair foreground scheduling, provider/generation fencing and separated fixed/marginal page-cost fitting. Preserves returned/serialized sync failures and checks chain compatibility before writes. Repairs stale selected/input/broadcast proofs against canonical headers and paths, commits proof corrections with compare-and-set, and fences concurrent monitor repairs. Optimizes unfiltered IndexedDB label offsets without a schema change. Reduces redundant storage promise forwarding while retaining authorization inside exclusive ownership and preserving rejection and queue-release behavior. Shares the remote forwarding rejection boundary and avoids uncontended priority searches and repeated queue/checkpoint helper allocations without changing custom-transport rejection, wire, result or fairness contracts. Shares reader/writer admission helpers without moving authorization across the queue boundary, and uses a Map of property descriptors for in-place entity normalization without an intermediate mapping array. Date, null, byte, record-identity and non-enumerable own-field contracts are retained. Includes the 2.14.4 BRC-100 compatibility fixes: bounded certifier descriptions, requested discovery pagination, signed no-send change outpoints, originator-bound no-send aborts, and receiver-free browser fetch calls in chaintracker clients. WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Runs sequential storage and proof operations through a lazy asynchronous producer while retaining the eight-worker proof bound, failure draining, deterministic transaction order and Promise rejection contracts. Operations are pulled on demand rather than queued eagerly. Captures portable BRC-38 source metadata and all table reads in one provider read view and normalizes absent legacy JSON object fields without mutating source records or dropping array entries. Adds local retained SQLite/MySQL read views with bounded lifetime, single-read admission and physical cleanup ownership; these are excluded from RPC. Adds the version-one local wallet read-snapshot contract; SQL providers deliver profile-bound keyset pages with packed binary columns and payload-size preflight before fetching rows. Integrates supported ordinary local SQL push/pull/backup with a dedicated source reader and atomic durable destination sessions, normalized ID mappings and primary-epoch fencing. Adds syncToWriterResumable and exported local capability/checkpoint types; browser/mobile type availability does not add retained database or remote adapters. Push and backup preserve stored source primary metadata while pull retains the destination selection, including serialized fallback with an older manager cache. Adds internal shared SQL snapshot staging with immutable completed pages, exact retry receipts, profile isolation, bounded logical reservations and cleanup recovery. Adds a local SQL capture controller that binds original metadata/schema to one retained view, checks profile relationships, stores all thirteen raw tables in bounded binary frames and cleans up cancelled or failed captures. Adds bounded metadata-only receipt directories and source/profile/root validation for arbitrary-table page reads without fetching preceding payloads. Exact binding preimages and complete thirteen-table inclusion are preserved. Adds an internal durable creation-request lifecycle with deadline-bound request IDs, shared capacity admission, atomic archive assignment/ready publication and bounded terminal receipt retention. Integrates its capture controller with the provider-owned reader slot, reserves before pool acquisition, drains cancellation/shutdown and preserves ready archives across controller replacement. Physical cleanup failures retain reservations and fence admission, including source-opening failures. Adds a negotiated authenticated snapshot archive transport with prompt durable admission, exact profile-bound status/directory/page/cancel methods, client receipt/root validation and a dedicated two-MiB pre-parse response limit. Expiry reaping precedes admission; resource-limit terminal receipts remain distinct. HTTP shutdown awaits physical capture cleanup. This low-level transport does not yet integrate remote rows with ordinary sync/export or complete #544. Adds an unadvertised immutable remote row reader with v2 server-issued offers, require-existing admission, bounded same-request recovery, fixed leases and packed table paging. Source-only client adapters integrate with local sync through optional provider capabilities; archive positions are detached, checked and atomically persisted. Failure receipts remain observable until cancellation, and typed cancellation preserves unrelated errors. Server reader advertisement stays disabled pending cross-replica owner/physical-cleanup qualification. Preserves the public hash-wasm Argon2id generic contract in emitted declarations without exposing the untyped internal bundle. Adds a durable exact-claim source-owner fence: cross-controller cancellation and both request/archive cleanup retain quota until awaited source cleanup is acknowledged, and page append checks the request fence atomically. Adds fixed-slot backend guards and exact-owner recovery after physical connection closure on local SQLite WAL and same-server MySQL. Old unguarded owners remain reserved and changed backend identities fail closed; reader advertisement stays disabled pending complete qualification. Validates exact request-bound pending-cleanup receipts and polls cancellation with one fixed 30-second cleanup allowance, at most 32 attempts and bounded backoff, without renewing the source lease or hiding independent failures. Aborted I/O settles before cleanup returns. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Keeps the provider source slot fenced after unproved native cleanup, even after its read promise settles; ordinary read failures remain retryable after proved cleanup. Adds auxiliary profile-key indexes for eight direct SQL tables without changing standard indexes or legacy OFFSET ordering; source triggers and resumable bootstrap keep membership coherent, and retained readers select the complete migration in their existing view. Adds auxiliary numeric relation keys for label/tag mappings with independent parent ownership bases, resumable composite bootstrap, retained-view selection and bounded MySQL primary-index page seeks before and after optimizer statistics refresh. Standard indexes, legacy OFFSET order and cursor/archive encodings remain unchanged. Adds resumable certificate-field indexes preserving source collation, exact text and independent direct/parent ownership, adopted by ordinary and archive readers inside their retained view. Adds resumable global proof/request reference indexes with exact counts, proof-presence guards and retained ordinary/archive selection; standard rows/indexes and BRC-38/cursor bytes are preserved. Adds conflict-safe SQLite auxiliary generations with metadata-bound displaced-identity witnesses, twelve bounded resumable bootstrap streams and bounded retirement of obsolete auxiliary rows. Reader adoption is pinned to journaled completion; MySQL maintenance and standard source records remain unchanged. Legacy SQLite retirement ownership binds object type and trigger name, refusing foreign views that share a trigger name before auxiliary data is retired. SQLite generation publication checks the configured migration-journal schema explicitly, preserving attached-schema journals and rejecting unrelated same-name publication records. Reconciles main\u2019s Postgres storage support while retaining its standard schema and legacy sync path; the current snapshot auxiliary migrations and capabilities remain limited to SQLite/MySQL. Adds internal owned journal capture: reserve two native pools before a nonwaiting writer fence, pin complete generation/profile/schema, commit the exact receipt and verify closure after releasing the barrier. Admission remains held through physical cleanup; an unproved close fences further sources. Preserves the original source/receipt failure alongside every rollback failure when owned journal transactions cannot prove cleanup; admission remains fenced until physical cleanup. Preserves queued primary reselection by checking no-op selections under existing ownership. A pending B switch followed by an A selection finishes with A, including after the earlier failure. A progress formatter failure before transition does not invalidate an in-flight sync. Invalidates cached primary authorization and reloads persisted selection after partial propagation failure; conflicting stores refuse active authorization, and recovery reload failures release queued ownership for retry. Allows exact proof and sync-checkpoint reads in provider-owned MySQL read-only snapshots without requesting write locks, while preserving row locking in ordinary writable transactions. Snapshot classification follows the transaction token across providers and is removed when its callback settles. Adds a complete critical read-snapshot consistency mutation target and generated direct/retained, cross-provider and independent-writer schedules, preserving ordinary unlocked lookups and checkpoint date/boolean normalization without changing the existing source API or isolation policy. Adaptive snapshot paging separates fixed and marginal costs, isolates current-table observations and conservatively forecasts actual payload charges. Reacts immediately to a newly expensive equal-size page while smoothing sustained work and payload recovery, retaining caller limits and table-bound forecasts. Adds unpublished optional /portable and /portable/node entries for bounded canonical BRC-38 processing, coherent SQL capture, standard BRC-39 framing/native crypto and private file quarantine. Existing materialized helpers and archive formats retain their contracts; these component APIs do not establish full issue #544 production qualification. Private-file appends now check intrinsic byte length before copying and detach Node Buffers and Buffer subviews before asynchronous writes. BRC-39 frame output also owns ciphertext supplied through Node Buffers and Buffer subviews, preserving the documented detachment contract. Global-index validation groups fresh bounded table and index metadata inside pinned SQLite transactions while preserving individual driver/oversized/unpinned fallbacks, public validator arity, partial/descending-index rejection and EXPLAIN checks. Fixes WAB faucet redemption for an empty wallet when storage adds a service charge. Local signer decisions for independently validated commission and derived change are retained privately through the permissions wrapper and bound by the SDK before signing. UMP pins anchor verified token-update lineage: password and token updates supersede their pinned predecessor, while unrelated historical continuations cannot override the anchor. Explicit overlay history is linked past confirmed Merkle anchors. Updates with missing pin ancestry remain indeterminate instead of selecting an unrelated continuation. Every retained token spend proves control through its unlocking script, including confirmed updates and hash rotation; funding paths do not establish token lineage. Corrects the property command argument order so all registered portable BRC-38/39 suites execute rather than becoming Jest ignore patterns, with an actual-parser governance regression.", + "migration": "These bounded fresh metadata reads and exact-definition pages require no API, wire or database-schema migration. Existing generation/journal installation requirements remain, and full #544 production qualification stays open. Unpublished journal generation helpers now require explicit receipt capacity/lifetime policy and refuse mismatched or earlier unregistered layouts. No registered migration or reader capability is added; capture publication, continuity-floor collection and receiver integration remain unfinished. Internal journal bootstrap callers must supply a fixed row allowance; this unpublished auxiliary layout rejects earlier unregistered generations rather than silently resetting their counters. Registered migration, reset and full resource-retention policy remain unfinished. The internal journal foundation adds no registered migration or public reader advertisement; full quota/capture/floor/receiver integration and full544 acceptance remain incomplete. The unchanged-backup checkpoint correction requires no API, wire or schema migration. Lost terminal acknowledgements resume from the durable destination checkpoint. Use syncFromReaderResumable for the new local atomic-page path; legacy/custom or remote destinations retain exclusive fallback. Cancellation drains in-flight work and resumes from the durable destination checkpoint. Custom providers opt into concurrent reads/atomic pages only when those contracts hold; safe proof persistence additionally requires compareAndSetProvenTxProof. Configured proof services must validate active 80-byte headers. The original legacy page/proof subset needs no migration. The legacy timestamp/offset path may reread equal-time rows and does not provide a coherent source snapshot or streaming archive. Choose maxRoughSize deliberately for device/proxy memory budgets. Current wallet releases remain on their published dependency pins. The included 2.14.4 compatibility fixes retain stored trust settings and require no additional wire or database migration. The included legacy compatibility fixes need no public API or wire migration. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. Custom StorageProvider implementations opt in with supportsReadSnapshot/readSnapshot and must honor the view token on every query. Pass requireSnapshot:true to exportBRC38/exportBRC38Json or exportBRC39 options to refuse unsupported coherent capture before table reads; old custom-provider calls retain their documented caller-quiesced legacy path. Built-in SQLite/MySQL and IndexedDB implement the local read view. MySQL configures the next transaction on one reserved connection without changing session defaults and closes failed connections before pool release. IndexedDB writers wait during capture; document/file materialization and the existing remote-client limitation remain. This intermediate checkpoint does not complete #544 or change BRC-38/39 wire bytes. Retained views occupy a pool connection and require awaited close/closed cleanup; configure driver deadlines separately. IndexedDB retention remains unsupported. The retained-view primitive introduces no schema or wire migration. openWalletReadSnapshot is an additive local SQL capability, excluded from RPC and unsupported on IndexedDB. Its cursor lives only as long as the retained view; restart after expiry or process loss. Page limits bound rows and conservative stored-payload charges, not wire bytes or RSS; oversized individual rows explicitly refuse pending large-value streaming. The page primitive preserves standard-table indexes and legacy OFFSET/checkpoint behavior. For the new local SQL sync path apply 2026-09-30-001 add durable snapshot sync through migrate(): three auxiliary tables plus a primary-change trigger preserve legacy sync-map JSON. SQLite requires file-backed WAL; MySQL requires a static database connection. Explicit row/reference/retention limits and unsupported configurations use serialized fallback. snapshotSync:false on current binaries is the supported forward rollback with schema retained; stop sessions before any binary downgrade, whose older migration code may reject newer journal entries. Source loss restarts traversal under a new view with durable mappings retained. Primary reconciliation, remote/IDB retained support, streaming and staged restore remain required; #544 is incomplete. No BRC-38/39 bytes change. Apply 2026-09-30-002 add snapshot archive staging through the normal SQL migrate() entry point. Its capacity, manifest and page tables are auxiliary and excluded from BRC-38; no standard-table or legacy-checkpoint change is made. Current staging ceilings are eight handles, 128 MiB reserved globally, 32 MiB per capture, 1 MiB per page, 4096 pages and one hour lifetime. Reservations remain occupied through physical page cleanup. These preliminary limits do not qualify larger-wallet remote export; canonical portable semantic validation and operator resource policy are still required. The local capture controller requires a dedicated reader and an independent staging pool, with schema migrations outside active capture windows; it exposes no RPC or canonical BRC-38 output. Existing sync/backup behavior and browser/mobile adapters are unchanged by this staging component. The internal receipt directory requires no additional schema migration and exposes no RPC capability. Its one-MiB metadata budget does not replace a transport envelope limit; inclusion integrity is separate from portable semantic/proof validation. The existing unpublished 2.15 minor candidate covers these packed core bytes; no package is published by this change. Apply the additive 2026-09-30-003 add snapshot archive requests migration through migrate(); its auxiliary receipts stay outside portable wallet data. Pending requests use the existing archive capacity counters. Close outstanding requests before removing the request schema. The internal controller caps source lifetime at five minutes or the request remaining deadline and keeps the archive deadline fixed. Its per-process reader bound is distinct from shared logical staging quotas; driver cleanup deadlines remain an operator concern. The authenticated archive transport below consumes these internal components; remote row/manager integration and contention performance qualification remain incomplete. The additive getSnapshotArchiveTransport(identityKey) client API requires an authenticated compatible advertisement; old or disabled peers decline it. Server capability requires the existing archive/request migrations, static WAL/MySQL support, snapshotSync enabled and sufficient envelopes. Set snapshotArchives:false on either server or client for rollback with schema retained. Keep the exact creation tuple/deadline for lost-response recovery; never substitute a new source after active work begins. Existing ordinary RPC, BRC-38/39 bytes and public CORS behavior are preserved. The additive snapshotArchiveReaderVersion:1 setting is separate from legacy snapshotArchive capability; current servers do not yet advertise it. Existing clients and old servers keep their established path. Cursor archivePosition metadata is optional and legacy cursor JSON is unchanged. Ordinary remote source use requires completed lifecycle qualification; no distributed physical-drain or new remote destination guarantee is provided. The Argon2id declaration correction requires no migration or runtime change; SDK peer ranges remain unchanged. Apply 2026-10-01-001 add snapshot archive source owners before service capture. Stop and drain captures before downgrading; the migration refuses removal while owners remain and older draft binaries must not capture concurrently. Old unguarded owners retain ownership after unproved process loss. Standard tables and BRC-38/39 bytes are unchanged. The unadvertised v2 reader path distinguishes pending cleanup from the existing true acknowledgement. Use matching candidate clients to wait for that acknowledgement; an older strict reader rejects the new pending receipt rather than claiming completion. Legacy cancellation retains its existing success/error response shapes; it does not use the new v2 pending receipt. Apply 2026-10-01-002 add snapshot archive source guards through migrate(). Preserve the eight persistent slot bindings and SQLite sidecar guard files; never replace a bound file to free quota. New captures require better-sqlite3 WAL or the same actual MySQL server/database. Missing or changed backend identity refuses recovery. Drain all captures before downgrade; old unguarded owners require explicit source cleanup. Distributed filesystems, database relocation/restoration, PXC/load-balanced failover and global physical-connection limits require separate qualification. Reader advertisement stays disabled pending complete lifecycle qualification. After a source-cleanup error, do not reuse the affected provider for snapshot capture; retain its owner reservation until backend recovery proves physical closure. This correction adds no schema or wire change. Apply 2026-10-01-003 add snapshot profile key indexes through migrate(). This explicit nontransactional migration commits bounded 256-row auxiliary key/progress batches and journals only completion; preserve partial objects for retry, verify a terminated migrator before recovering any stale Knex lock, and drain readers before down. Standard rows/indexes and BRC-38/cursor bytes remain unchanged. Relationship/global-table indexing, commit-order high-water and nonblocking IndexedDB remain incomplete. Apply 2026-10-01-004 add snapshot relation key indexes through migrate(). Install/removal observers and producers preserve both ownership bases, including inconsistent mappings for closure refusal. Preserve partial auxiliary DDL/progress for retry, exclude other migrators before lock recovery, and drain readers before down. This migration remains outside BRC-38. Certificate-field/proof indexing, committed-change ordering, streaming and staged import remain incomplete; reader advertisement remains disabled. Apply 2026-10-01-005 add snapshot certificate field key indexes through migrate(). MySQL source and auxiliary tables require InnoDB and matching varchar(100) character set/collation; SQLite verifies the existing complete unique key and built-in BINARY/NOCASE/RTRIM order. Preserve partial owned DDL and committed bootstrap positions, exclude other migrators before verified lock recovery, and drain retained readers before down. Standard tables, indexes, legacy OFFSET and BRC-38/cursor bytes remain unchanged; proof/request indexing and the full program remain incomplete. Apply 2026-10-01-006 add snapshot global reference indexes through migrate(). Its reference edges, exact counts, presence guards and progress are auxiliary and excluded from BRC-38. Preserve partial DDL/committed positions; prove a stopped migrator and exclude others before lock recovery. MySQL requires standard unsigned/text metadata and InnoDB with explicit RESTRICT/NO ACTION mutations; implicit CASCADE/SET NULL actions are refused before DDL. Drain readers before down. The complete journal and progress are adopted within each pinned ordinary/archive view; source rows/indexes, legacy OFFSET and wire bytes stay unchanged. Commit ordering, tombstones, nonblocking IDB, remote destinations, streaming/staged portability and full acceptance remain incomplete; reader advertisement remains off. Apply 2026-10-02-001 repair snapshot SQLite conflict maintenance through migrate(). Preserve partial owned generation/progress for recovery and prove the stopped migrator before stale lock recovery. New readers use source queries while pending and adopt v2 inside the same retained view only after journal publication. Do not downgrade older snapshot binaries against invalidated legacy progress. Ordinary down refuses without deleting source rows; use a forward migration. Explicit dropAllData still deletes the complete wallet, including unpublished generation state. Freed SQLite pages may remain allocated for reuse; bounded retirement does not promise file shrink or full resource-budget acceptance. The SQLite migration-journal lookup correction needs no schema or wire migration; retain the existing tableName/schemaName binding. Postgres records these auxiliary snapshot migrations as no-ops and advertises no retained or paged source. Future Postgres snapshot support needs new forward migrations rather than rewriting the recorded entries. Internal openSnapshotJournalSource requires an existing file-backed better-sqlite3 WAL database or static mysql2 connection and an already complete explicitly owned journal generation. It registers no migration or incremental capability. A killed read view must be captured anew; retained prefix receipts do not reopen it. Runtime quotas, registered journal lifecycle, delta paging and receiver integration remain pending. Internal maintainSnapshotJournal owns generation validation and configured Knex migration exclusion before each bounded floor/collection transaction. Raw operator DDL remains outside that exclusion contract. Cancellation keeps shared capture admission until rollback or committed-result recovery and physical pool cleanup; unproved cleanup fences retained admission and remains observable through destruction. Runtime quotas, registered journal lifecycle/recovery and incremental reader advertisement remain unfinished; no new registered migration or public wire format is introduced. The queued setActive correction requires no API, wire or schema migration. Primary reconciliation remains serialized pending the complete #544 implementation. Adaptive snapshot paging isolates observations by table, separates fixed page latency from marginal row cost, and uses the actual committed payload charge to forecast the next request. A known next table starts at the original 64-row budget; repeated observations of the same table retain their history. Caller row and byte limits remain authoritative, and an oversized individual row still refuses without advancing the checkpoint. These forecasts do not establish a WAL, allocation or memory bound. This change needs no wire or schema migration. Full #544 qualification and production performance evidence remain required. Optional streaming adoption requires importing the new subpaths and supplying explicit resource ceilings, a coherent validated source and trusted private staging with awaited cleanup. Authenticated plaintext must pass complete semantic/provenance validation before use; hosts remain responsible for durable save/import transactions. Node adapters are Node-only. No existing API, wire-format or database-schema migration is required; native quota, total-memory, remote/platform and durable recovery qualification remain open. Buffer callers retain the Uint8Array contract; private-file appends own the supplied bytes before returning their pending write. BRC-39 frame input reuse requires no API, file-format or schema migration; emitted ciphertext is owned for regular byte arrays, Node Buffers and their subviews. Upgrade the SDK to a release exposing completeBoundAction.outputAuthorizationVersion=1 alongside wallet-toolbox 2.14.6. SDK2 peers keep their existing strict behavior; they do not obtain this fee-bearing faucet fix. The main SDK3.1 candidate retains the separate SDK3 retirement migration; SDK2 hosts require a coordinated additive backport or that migration. Serialized or cross-package-instance result adapters discard local authority and fail closed. Signup interruption/retry persistence is unchanged; reconcile any previous faucet transaction before repeating a failed signup. No API or schema migration is required for pin continuity. Retire a support pin only after unpinned lookup independently returns the verified current token. Deploy overlay 2.6.4 plus overlay-topics 2.0.1 or an equivalent history decider for confirmed ancestry. Stored WAB pins remain lineage anchors; the client follows verified descendants without needing an administrative pin rewrite. No additional API, storage or runtime migration is needed for this test-command correction; run the existing test:property command to include its complete declared suite list." }, { "name": "@bsv/wallet-toolbox-client", diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index a16dda989..3243bf369 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -1375,6 +1375,12 @@ branches, 42.57% functions, and 45.46% lines; that collection includes imported `out/src` code as well as source files. Use the exact run's coverage report, rather than comparing unlike source-only and combined collections. +`pnpm test:property` selects every registered Wallet Toolbox property suite, including +the portable BRC-38/39 suites. Keep the manual-test ignore option before the +boolean runner options and file list: Jest treats it as an array and otherwise +consumes appended file paths as exclusions. The root governance regression checks +the actual Jest argument parser and the complete registered selection. + Operational repair, migration, export, and long-running service procedures are not tests. They live under [`operator/`](./operator/README.md), produce an exact dry-run plan by default, and require explicit confirmation before they write diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 5cf7fa12f..88d84fe7d 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -65,7 +65,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts src/storage/sync/SyncPageBudget.property.test.ts --testPathIgnorePatterns=man.test.ts src/storage/portable/CanonicalPortableChunks.property.test.ts src/storage/portable/Brc38PackedRow.test.ts src/storage/portable/Brc38Stream.test.ts src/storage/portable/Brc38KnexSource.test.ts src/storage/portable/Brc38JsonStream.property.test.ts src/storage/portable/Brc39Frame.property.test.ts src/storage/portable/Brc39StreamNode.test.ts src/storage/portable/Brc39PrivateFileNode.test.ts", + "test:property": "jest --testPathIgnorePatterns=man.test.ts --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts src/storage/sync/SyncPageBudget.property.test.ts src/storage/portable/CanonicalPortableChunks.property.test.ts src/storage/portable/Brc38PackedRow.test.ts src/storage/portable/Brc38Stream.test.ts src/storage/portable/Brc38KnexSource.test.ts src/storage/portable/Brc38JsonStream.property.test.ts src/storage/portable/Brc39Frame.property.test.ts src/storage/portable/Brc39StreamNode.test.ts src/storage/portable/Brc39PrivateFileNode.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index bfc00f697..c429172dc 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { createRequire } from 'node:module' import test from 'node:test' import { resolveGovernedTest } from './run-governed-test.mjs' @@ -154,3 +155,25 @@ test('governed test runner rejects traversal and wrong test modes', () => { fs.rmSync(temporaryDirectory, { recursive: true, force: true }) } }) + +test('wallet property command selects every registered suite with the actual Jest parser', async () => { + const manifest = 'packages/wallet/wallet-toolbox/package.json' + const packageDirectory = path.dirname(path.join(REPOSITORY_ROOT, manifest)) + const wallet = JSON.parse(fs.readFileSync(path.join(REPOSITORY_ROOT, manifest), 'utf8')) + const walletRequire = createRequire(path.join(packageDirectory, 'package.json')) + const jestRequire = createRequire(walletRequire.resolve('jest/package.json')) + const { buildArgv } = jestRequire('jest-cli') + const tokens = wallet.scripts['test:property'].split(' ') + assert.equal(tokens.shift(), 'jest') + const declared = tokens.filter(value => value.startsWith('src/') && value.endsWith('.test.ts')) + const registered = policy.propertyTesting.suites + .filter(suite => suite.manifest === manifest) + .map(suite => path.relative(packageDirectory, path.join(REPOSITORY_ROOT, suite.path))) + assert.deepEqual(new Set(declared), new Set(registered)) + const actual = await buildArgv(tokens) + assert.deepEqual(actual._, declared) + assert.deepEqual(actual.testPathIgnorePatterns, ['man.test.ts']) + assert.equal(actual.runInBand, true) + assert.equal(actual.runTestsByPath, true) + assert.equal(actual.watchman, false) +}) From 414794bd303967d50945d450f99031124d4ff178 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 5 Oct 2026 21:32:37 -0700 Subject: [PATCH 125/127] fix(ci): keep early property governance checks dependency-free --- packages/wallet/wallet-toolbox/package.json | 2 +- scripts/check-wallet-property-command.mjs | 33 +++++++++++++++++++++ scripts/test-governance.test.mjs | 29 +++++++++--------- 3 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 scripts/check-wallet-property-command.mjs diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 88d84fe7d..eca659b16 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -65,7 +65,7 @@ "test": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --watchman=false", "test:watch": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|client/test|mobile/test' --watch", "test:coverage": "pnpm build && jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test' --coverage --watchman=false", - "test:property": "jest --testPathIgnorePatterns=man.test.ts --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts src/storage/sync/SyncPageBudget.property.test.ts src/storage/portable/CanonicalPortableChunks.property.test.ts src/storage/portable/Brc38PackedRow.test.ts src/storage/portable/Brc38Stream.test.ts src/storage/portable/Brc38KnexSource.test.ts src/storage/portable/Brc38JsonStream.property.test.ts src/storage/portable/Brc39Frame.property.test.ts src/storage/portable/Brc39StreamNode.test.ts src/storage/portable/Brc39PrivateFileNode.test.ts", + "test:property": "node ../../../scripts/check-wallet-property-command.mjs && jest --testPathIgnorePatterns=man.test.ts --runInBand --watchman=false --runTestsByPath src/utility/__tests/actionBatchPack.property.test.ts src/storage/snapshot/RetainedReadSnapshot.property.test.ts src/storage/snapshot/SnapshotSync.property.test.ts src/storage/snapshot/SnapshotSyncDestination.property.test.ts src/storage/snapshot/SnapshotSyncRows.property.test.ts src/storage/snapshot/archive/KnexSnapshotArchiveStore.property.test.ts src/storage/snapshot/archive/SnapshotArchiveDirectory.property.test.ts src/storage/snapshot/archive/SnapshotArchiveService.property.test.ts src/storage/snapshot/archive/SnapshotArchiveProtocol.property.test.ts src/storage/snapshot/archive/RemoteSnapshotReader.property.test.ts src/storage/snapshot/journal/SnapshotJournal.property.test.ts src/storage/portable/mysqlReadSnapshot.property.test.ts src/storage/sync/SyncPageBudget.property.test.ts src/storage/portable/CanonicalPortableChunks.property.test.ts src/storage/portable/Brc38PackedRow.test.ts src/storage/portable/Brc38Stream.test.ts src/storage/portable/Brc38KnexSource.test.ts src/storage/portable/Brc38JsonStream.property.test.ts src/storage/portable/Brc39Frame.property.test.ts src/storage/portable/Brc39StreamNode.test.ts src/storage/portable/Brc39PrivateFileNode.test.ts", "test:manual": "pnpm build && node ../../../scripts/run-governed-test.mjs manual", "test:live": "pnpm build && node ../../../scripts/run-governed-test.mjs live", "operator:build": "pnpm build && tsc --project operator/tsconfig.json --pretty false", diff --git a/scripts/check-wallet-property-command.mjs b/scripts/check-wallet-property-command.mjs new file mode 100644 index 000000000..bffea494a --- /dev/null +++ b/scripts/check-wallet-property-command.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const manifest = 'packages/wallet/wallet-toolbox/package.json' +const packageDirectory = path.dirname(path.join(root, manifest)) +const wallet = JSON.parse(fs.readFileSync(path.join(root, manifest), 'utf8')) +const policy = JSON.parse( + fs.readFileSync(path.join(root, 'governance/test-quality/policy.json'), 'utf8') +) +const prefix = 'node ../../../scripts/check-wallet-property-command.mjs && jest ' +assert.ok(wallet.scripts['test:property'].startsWith(prefix)) +const tokens = wallet.scripts['test:property'].slice(prefix.length).split(' ') +const declared = tokens.filter(value => value.startsWith('src/') && value.endsWith('.test.ts')) +const registered = policy.propertyTesting.suites + .filter(suite => suite.manifest === manifest) + .map(suite => path.relative(packageDirectory, path.join(root, suite.path))) +assert.deepEqual([...declared].sort(), [...registered].sort()) + +// Resolve the same installed parser as the wallet's Jest command, without discovering tests. +const walletRequire = createRequire(path.join(packageDirectory, 'package.json')) +const jestRequire = createRequire(walletRequire.resolve('jest/package.json')) +const { buildArgv } = jestRequire('jest-cli') +const actual = await buildArgv(tokens) +assert.deepEqual(actual._, declared) +assert.deepEqual(actual.testPathIgnorePatterns, ['man.test.ts']) +assert.equal(actual.runInBand, true) +assert.equal(actual.runTestsByPath, true) +assert.equal(actual.watchman, false) +console.log(`Wallet property command selects all ${declared.length} registered suites.`) diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index c429172dc..76177e060 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -2,7 +2,6 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { createRequire } from 'node:module' import test from 'node:test' import { resolveGovernedTest } from './run-governed-test.mjs' @@ -156,24 +155,24 @@ test('governed test runner rejects traversal and wrong test modes', () => { } }) -test('wallet property command selects every registered suite with the actual Jest parser', async () => { +test('wallet property command keeps every registered suite after its runner options', () => { const manifest = 'packages/wallet/wallet-toolbox/package.json' const packageDirectory = path.dirname(path.join(REPOSITORY_ROOT, manifest)) const wallet = JSON.parse(fs.readFileSync(path.join(REPOSITORY_ROOT, manifest), 'utf8')) - const walletRequire = createRequire(path.join(packageDirectory, 'package.json')) - const jestRequire = createRequire(walletRequire.resolve('jest/package.json')) - const { buildArgv } = jestRequire('jest-cli') - const tokens = wallet.scripts['test:property'].split(' ') - assert.equal(tokens.shift(), 'jest') - const declared = tokens.filter(value => value.startsWith('src/') && value.endsWith('.test.ts')) + const prefix = 'node ../../../scripts/check-wallet-property-command.mjs && jest ' + assert.ok(wallet.scripts['test:property'].startsWith(prefix)) + const tokens = wallet.scripts['test:property'].slice(prefix.length).split(' ') + const options = [ + '--testPathIgnorePatterns=man.test.ts', + '--runInBand', + '--watchman=false', + '--runTestsByPath' + ] + assert.deepEqual(tokens.slice(0, options.length), options) + const declared = tokens.slice(options.length) const registered = policy.propertyTesting.suites .filter(suite => suite.manifest === manifest) .map(suite => path.relative(packageDirectory, path.join(REPOSITORY_ROOT, suite.path))) - assert.deepEqual(new Set(declared), new Set(registered)) - const actual = await buildArgv(tokens) - assert.deepEqual(actual._, declared) - assert.deepEqual(actual.testPathIgnorePatterns, ['man.test.ts']) - assert.equal(actual.runInBand, true) - assert.equal(actual.runTestsByPath, true) - assert.equal(actual.watchman, false) + assert.ok(declared.every(value => value.startsWith('src/') && value.endsWith('.test.ts'))) + assert.deepEqual([...declared].sort(), [...registered].sort()) }) From ba3bd8550f4c9a13d9136ca653bd143a0e1b222b Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 5 Oct 2026 21:39:25 -0700 Subject: [PATCH 126/127] fix(governance): sort property paths with an explicit comparator --- scripts/check-wallet-property-command.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/check-wallet-property-command.mjs b/scripts/check-wallet-property-command.mjs index bffea494a..41e3f35c7 100644 --- a/scripts/check-wallet-property-command.mjs +++ b/scripts/check-wallet-property-command.mjs @@ -18,7 +18,12 @@ const declared = tokens.filter(value => value.startsWith('src/') && value.endsWi const registered = policy.propertyTesting.suites .filter(suite => suite.manifest === manifest) .map(suite => path.relative(packageDirectory, path.join(root, suite.path))) -assert.deepEqual([...declared].sort(), [...registered].sort()) +function comparePaths(left, right) { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} +assert.deepEqual([...declared].sort(comparePaths), [...registered].sort(comparePaths)) // Resolve the same installed parser as the wallet's Jest command, without discovering tests. const walletRequire = createRequire(path.join(packageDirectory, 'package.json')) From f10f70f8e13b44aa6792867d477a733a3092c3c4 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 6 Oct 2026 10:08:33 -0700 Subject: [PATCH 127/127] fix: retire obsolete watcher governance after main synchronization --- docs/reference/dependency-policy.md | 25 +-- governance/dependency-release-policy.json | 32 +-- governance/repository-health/exceptions.json | 19 -- governance/service-runtime-copy-policy.json | 18 +- infra/uhrp-server-basic/dev/bin/nodemon.js | 48 ----- infra/uhrp-server-basic/dev/check-watch.cjs | 195 ------------------ .../dev/bin/nodemon.js | 48 ----- .../dev/check-watch.cjs | 195 ------------------ infra/wab/dev/bin/nodemon.js | 48 ----- infra/wab/dev/check-watch.cjs | 195 ------------------ .../dependency-release-governance.test.mjs | 8 +- sonar-project.properties | 6 +- 12 files changed, 10 insertions(+), 827 deletions(-) delete mode 100644 infra/uhrp-server-basic/dev/bin/nodemon.js delete mode 100644 infra/uhrp-server-basic/dev/check-watch.cjs delete mode 100644 infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js delete mode 100644 infra/uhrp-server-cloud-bucket/dev/check-watch.cjs delete mode 100644 infra/wab/dev/bin/nodemon.js delete mode 100644 infra/wab/dev/check-watch.cjs diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index 123bf16a7..3fdb24ff7 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -170,29 +170,6 @@ all three together when an official compatible release removes the affected path and the full compatibility, frozen graph, audit and platform checks pass. This build-tool repair does not change published wallet APIs or package versions. -## Temporary standalone service watcher repair - -The basic and cloud UHRP services and WAB use Nodemon 3.1.14 with a parent-scoped -Chokidar 4.0.3 substitution, removing the same affected braces closure without -an advisory exclusion. Nodemon already resolves watched globs to literal -directories; the source-owned adapter retains the old anymatch 3 ignore -semantics, including recursive literal directories, custom cwd, dotfiles, -regex and function options. WAB replaces ts-node-dev with this same CLI. -The existing Node/ts-node/telemetry execution paths remain intact, with explicit -TypeScript and env extensions, manual `rs` and graceful shutdown regressions -run before each service's ordinary tests. The service-copy policy keeps both -adapter and native regression identical across all three service contexts. - -The adapter normalizes every backslash and repeated slash without changing its -ignore matching. Native watcher polling permits 400 predicate attempts, with a -25 ms wait after every failed attempt, and preserves early process-exit errors. - -The three new registered substitutions bring the combined retained count to 30. Remove them and the adapter together after a compatible official Nodemon -release resolves the dependency path natively and all watcher, frozen audit, -service and protected Linux image gates pass. These standalone development -tools change no public npm candidate version, service HTTP API, production -startup or persisted schema; deployed images require separate promotion. - ## Supply-chain controls `pnpm-workspace.yaml` is the source of truth for installation controls: @@ -274,7 +251,7 @@ compatibility checks, not a throughput or memory benchmark. No public npm version or consumer migration changes; protected Linux image and exact-head analysis gates still qualify the eventual service artifacts before promotion. -The combined workspace and standalone registries carry 30 audited dependency overrides, including: +The combined workspace and standalone registries carry 27 audited dependency overrides, including: - Jest 30.5.1 and Stryker still constrain parts of their reporting and coverage graphs to minimatch releases with older `brace-expansion` ranges. The follow-up diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index c31fffbef..f5a4cf871 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -145,10 +145,10 @@ }, "overrideRemovalReview": { "reviewedAt": "2026-10-03", - "method": "Rechecked every registered substitution after the workspace brace-expansion floor moved from 5.0.9 to 5.0.12 and engine.io 6.6.10 was added. Jest and Stryker minimatch still admit brace-expansion below 5.0.12, and socket.io in authsocket still admits engine.io below 6.6.10. Both substitutions stay on the first release that clears the current high advisories. The other 23 selectors, including the Metro-scoped image-size 2.0.4 substitution, remain necessary against the frozen graph. On 2026-10-02 the lodash-es 4.18.1 selector was added because Mermaid 12 brings chevrotain 11.1.2, which pins vulnerable lodash-es 4.17.23. The yamux-scoped @libp2p/utils 7.4.1 selector was added so YamuxStream satisfies @libp2p/interface 3.3 readableEnded. Metro 0.87.1 replaced its image-size dependency with an in-tree parser, so the Metro-scoped image-size substitution was retired on 2026-10-02. Reconciliation with #569 retains its previously qualified 5.0.12 floor for GHSA-q2hr-2g5m-vwhr in both workspace and standalone graphs. On 2026-10-02, no released Metro/file-map/micromatch/braces upgrade removes high GHSA-vfj7-8cjw-p6xm. The exact Metro-file-map0.87.1 watcher patch uses its existing Picomatch2.3.2 some() matcher and removes only scoped micromatch and its unused braces closure; all importers/settings and other package resolutions remain unchanged. On 2026-10-03, three standalone Nodemon-parent Chokidar4.0.3 substitutions remove the affected braces closure. WAB replaces ts-node-dev with the same Nodemon CLI; an owned adapter preserves the old glob-aware ignore predicate and the existing Node/ts-node/telemetry execution contracts. Native restart and shutdown regressions cover each locked service.", - "retainedCount": 30, - "result": "All 30 retained overrides prevent a reproduced vulnerable transitive version, keep a stream type assignable, or preserve an isolated reproducible toolchain closure. The workspace brace-expansion selector is now 5.0.12, covering GHSA-6j4f-fj2g-mc7p and GHSA-qhr7-859c-m2p7. The engine.io 6.6.10 selector closes GHSA-2gc4-cqfq-p2gv on the authsocket socket.io path without an advisory exclusion. The lodash-es 4.18.1 selector closes GHSA-r5fr-rjxr-66jc on the Mermaid/chevrotain path. The yamux-scoped @libp2p/utils 7.4.1 selector supplies readableEnded for interface 3.3. The brace-expansion floor also covers GHSA-q2hr-2g5m-vwhr; no advisory exclusion is introduced. The Metro-file-map exact-version repair removes the affected dependency closure without an audit exclusion; source-owned watcher compatibility, packed Metro/Hermes and complete affected gates remain required. The three scoped Nodemon substitutions remove the affected standalone watcher closure without an advisory exclusion; their source-owned compatibility adapter and complete service/hosted image gates remain required.", - "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch/brace-expansion/js-yaml, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO/engine.io, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, image-size, or Mermaid/chevrotain/lodash-es dependency changes. Remove the paired Metro watcher patch, exact package extension and scoped removal together once an official compatible release removes the affected closure and all watcher/platform gates pass. Remove the standalone Nodemon substitutions and adapter together after an official compatible Nodemon release resolves the dependency path natively and all ignore/restart/shutdown, frozen audit, service and Linux image checks pass." + "method": "Rechecked every registered substitution after the workspace brace-expansion floor moved from 5.0.9 to 5.0.12 and engine.io 6.6.10 was added. Jest and Stryker minimatch still admit brace-expansion below 5.0.12, and socket.io in authsocket still admits engine.io below 6.6.10. Both substitutions stay on the first release that clears the current high advisories. The other 23 selectors, including the Metro-scoped image-size 2.0.4 substitution, remain necessary against the frozen graph. On 2026-10-02 the lodash-es 4.18.1 selector was added because Mermaid 12 brings chevrotain 11.1.2, which pins vulnerable lodash-es 4.17.23. The yamux-scoped @libp2p/utils 7.4.1 selector was added so YamuxStream satisfies @libp2p/interface 3.3 readableEnded. Metro 0.87.1 replaced its image-size dependency with an in-tree parser, so the Metro-scoped image-size substitution was retired on 2026-10-02. Reconciliation with #569 retains its previously qualified 5.0.12 floor for GHSA-q2hr-2g5m-vwhr in both workspace and standalone graphs. On 2026-10-02, no released Metro/file-map/micromatch/braces upgrade removes high GHSA-vfj7-8cjw-p6xm. The exact Metro-file-map0.87.1 watcher patch uses its existing Picomatch2.3.2 some() matcher and removes only scoped micromatch and its unused braces closure; all importers/settings and other package resolutions remain unchanged. On 2026-10-06, reconcile the qualified main633 watcher replacement: all three services now use the shared tsx watch launcher and no longer declare Nodemon. Retire only their three Nodemon-parent substitutions and compatibility adapters, retaining the qualified brace-expansion 5.0.12 floor and all other 27 registrations.", + "retainedCount": 27, + "result": "All 27 retained overrides prevent a reproduced vulnerable transitive version, keep a stream type assignable, or preserve an isolated reproducible toolchain closure. The workspace brace-expansion selector is now 5.0.12, covering GHSA-6j4f-fj2g-mc7p and GHSA-qhr7-859c-m2p7. The engine.io 6.6.10 selector closes GHSA-2gc4-cqfq-p2gv on the authsocket socket.io path without an advisory exclusion. The lodash-es 4.18.1 selector closes GHSA-r5fr-rjxr-66jc on the Mermaid/chevrotain path. The yamux-scoped @libp2p/utils 7.4.1 selector supplies readableEnded for interface 3.3. The brace-expansion floor also covers GHSA-q2hr-2g5m-vwhr; no advisory exclusion is introduced. The Metro-file-map exact-version repair removes the affected dependency closure without an audit exclusion; source-owned watcher compatibility, packed Metro/Hermes and complete affected gates remain required. The three obsolete standalone Nodemon substitutions are retired with the removed watcher closure; production startup and all audit, service and hosted image gates remain unchanged.", + "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch/brace-expansion/js-yaml, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO/engine.io, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, image-size, or Mermaid/chevrotain/lodash-es dependency changes. Remove the paired Metro watcher patch, exact package extension and scoped removal together once an official compatible release removes the affected closure and all watcher/platform gates pass." }, "overrideRegistry": [ { @@ -317,30 +317,6 @@ "selector": "metro-file-map@0.87.1>micromatch", "value": "'-'", "exceptionId": "metro-file-map-picomatch-advisory-repair" - }, - { - "source": "infra/uhrp-server-basic/package.json", - "selector": "nodemon", - "value": { - "chokidar": "4.0.3" - }, - "exceptionId": "standalone-nodemon-chokidar-advisory-repair" - }, - { - "source": "infra/uhrp-server-cloud-bucket/package.json", - "selector": "nodemon", - "value": { - "chokidar": "4.0.3" - }, - "exceptionId": "standalone-nodemon-chokidar-advisory-repair" - }, - { - "source": "infra/wab/package.json", - "selector": "nodemon", - "value": { - "chokidar": "4.0.3" - }, - "exceptionId": "standalone-nodemon-chokidar-advisory-repair" } ], "scheduledVerification": { diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index 9b212dcce..e8b08c55e 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -433,25 +433,6 @@ "created": "2026-10-02", "reviewBy": "2026-11-01", "removeWhen": "Remove the patch, exact-version package extension and scoped removal together after an official compatible Metro/file-map release removes the affected dependency path; require the complete watcher oracle, fresh unexcluded audit, frozen graph and packed Metro/Hermes platform checks before retirement." - }, - { - "id": "standalone-nodemon-chokidar-advisory-repair", - "category": "override", - "target": "Nodemon-parent chokidar4.0.3 in infra/uhrp-server-basic, infra/uhrp-server-cloud-bucket and infra/wab", - "owner": "ts-stack-maintainers", - "reason": "No compatible released Nodemon update removes high GHSA-vfj7-8cjw-p6xm from its Chokidar3/braces graph. Nodemon already expands watch globs into literal directories. A source-owned adapter restores the former anymatch3 ignore predicate, including literal-directory recursion, custom cwd, regex/functions and dotfile behavior, while retaining the actual CLI, manual restart, Node/ts-node preloads and shutdown. WAB replaces ts-node-dev with the same tested watcher instead of forcing its unsupported glob API through Chokidar4. Only each Nodemon parent receives the substitution; production service startup and storage/HTTP contracts are unchanged. This is a temporary dependency repair, not an advisory exclusion.", - "evidence": [ - "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm", - "https://github.com/paulmillr/chokidar/releases/tag/4.0.3", - "https://github.com/remy/nodemon/blob/v3.1.14/bin/nodemon.js", - "infra/uhrp-server-basic/dev/bin/nodemon.js", - "infra/uhrp-server-basic/dev/check-watch.cjs", - "governance/service-runtime-copy-policy.json", - "https://github.com/bsv-blockchain/ts-stack/discussions/757" - ], - "created": "2026-10-03", - "reviewBy": "2026-11-03", - "removeWhen": "Remove all three scoped substitutions and the compatibility adapter together after an official compatible Nodemon release removes the affected dependency closure and preserves the full ignore/restart/preload/manual-restart/shutdown contract; require fresh frozen unexcluded audits, complete service builds/tests and protected Linux image qualification before retirement." } ] } diff --git a/governance/service-runtime-copy-policy.json b/governance/service-runtime-copy-policy.json index 142d362aa..6842a7ffe 100644 --- a/governance/service-runtime-copy-policy.json +++ b/governance/service-runtime-copy-policy.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "lastReviewed": "2026-10-03", + "lastReviewed": "2026-10-06", "owner": "ts-stack-maintainers", - "rationale": "Standalone image build contexts retain a small number of runtime sources that are canonically owned elsewhere. These copies are synchronized byte-for-byte so published packages and official images cannot drift. The same rule owns standalone development watcher adapters and their native regression tests.", + "rationale": "Standalone image build contexts retain a small number of runtime sources that are canonically owned elsewhere. These copies are synchronized byte-for-byte so published packages and official images cannot drift.", "copies": [ { "canonicalSource": "infra/uhrp-server-basic/src/resourceLimits.ts", @@ -112,20 +112,6 @@ { "canonicalSource": "infra/uhrp-server-basic/src/chirp/bodyMiddleware.ts", "synchronizedSources": ["infra/uhrp-server-cloud-bucket/src/chirp/bodyMiddleware.ts"] - }, - { - "canonicalSource": "infra/uhrp-server-basic/dev/bin/nodemon.js", - "synchronizedSources": [ - "infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js", - "infra/wab/dev/bin/nodemon.js" - ] - }, - { - "canonicalSource": "infra/uhrp-server-basic/dev/check-watch.cjs", - "synchronizedSources": [ - "infra/uhrp-server-cloud-bucket/dev/check-watch.cjs", - "infra/wab/dev/check-watch.cjs" - ] } ] } diff --git a/infra/uhrp-server-basic/dev/bin/nodemon.js b/infra/uhrp-server-basic/dev/bin/nodemon.js deleted file mode 100644 index fe89254b7..000000000 --- a/infra/uhrp-server-basic/dev/bin/nodemon.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict' - -const path = require('node:path') -const anymatch = require('anymatch') -const isGlob = require('is-glob') -const { bus } = require('nodemon/lib/utils') -const { rulesToMonitor } = require('nodemon/lib/monitor/match') - -function unix(value) { - const slashes = value.replaceAll('\\', '/') - return (slashes.startsWith('//') ? '/' : '') + slashes.replaceAll(/\/{2,}/g, '/') -} -function normalizeIgnored(value, cwd) { - if (typeof value !== 'string') return value - const joined = path.isAbsolute(value) ? value : path.join(cwd ?? '', value) - return unix(path.normalize(unix(joined))) -} - -/** Nodemon already expands watched globs into literal directories. Chokidar4 - * also needs the former glob-aware ignore predicate, including literal-directory - * recursion and custom ignored functions/regexes. Retain its published anymatch - * matcher instead of the removed braces-dependent watch-path expansion. */ -function applyOptions(config) { - const options = config.options.watchOptions ?? {} - let ignored - if (Object.hasOwn(options, 'ignored')) ignored = options.ignored - else { - ignored = rulesToMonitor([], Array.from(config.options.ignore), config).map(pattern => - pattern.slice(1) - ) - const dotFile = /[/\\]\./ - if (!config.dirs.some(directory => dotFile.test(directory))) ignored.push(dotFile) - } - const normalized = (Array.isArray(ignored) ? ignored : [ignored]).map(value => - normalizeIgnored(value, options.cwd) - ) - const directories = normalized - .filter(value => typeof value === 'string' && !isGlob(value)) - .map(value => value + '/**') - const predicate = anymatch([...normalized, ...directories], undefined, { dot: true }) - config.options.watchOptions = { ...options, ignored: (file, stats) => predicate([file, stats]) } -} - -module.exports = applyOptions -if (require.main === module) { - bus.on('config:update', applyOptions) - require('nodemon/bin/nodemon.js') -} diff --git a/infra/uhrp-server-basic/dev/check-watch.cjs b/infra/uhrp-server-basic/dev/check-watch.cjs deleted file mode 100644 index 7e380ce0e..000000000 --- a/infra/uhrp-server-basic/dev/check-watch.cjs +++ /dev/null @@ -1,195 +0,0 @@ -'use strict' - -const assert = require('node:assert/strict') -const fs = require('node:fs/promises') -const { readFileSync } = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { spawn } = require('node:child_process') -const { test } = require('node:test') -const applyOptions = require('./bin/nodemon.js') -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) - -test('development ignore options retain legacy glob, literal-directory, regex, function, cwd and dotfile behavior', () => { - const cases = [ - { ignored: '/repo/cache', file: '/repo/cache/child/file.ts', expected: true }, - { ignored: '/repo/cache', file: '/repo/cache-other/file.ts', expected: false }, - { ignored: 'cache', cwd: '/repo', file: '/repo/cache/file.ts', expected: true }, - { - ignored: String.raw`cache\nested`, - cwd: '/repo', - file: '/repo/cache/nested/file.ts', - expected: true - }, - { ignored: '**/generated/**', file: '/repo/generated/file.ts', expected: true }, - { ignored: '**/generated/**', file: '/repo/src/file.ts', expected: false }, - { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/drop.ts', expected: true }, - { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/keep.ts', expected: false }, - { ignored: /\.map$/, file: '/repo/file.map', expected: true }, - { ignored: /\.map$/, file: '/repo/file.ts', expected: false }, - { - ignored: (_file, stats) => stats?.marker === true, - file: '/repo/a.ts', - stats: { marker: true }, - expected: true - }, - { ignored: (_file, stats) => stats?.marker === true, file: '/repo/a.ts', expected: false }, - { ignored: undefined, file: '/repo/a.ts', expected: false }, - { ignored: [], file: '/repo/a.ts', expected: false } - ] - for (const item of cases) { - const config = { - dirs: ['/repo/src'], - options: { - ignore: [], - watchOptions: { ignored: item.ignored, cwd: item.cwd, usePolling: true, interval: 71 } - } - } - applyOptions(config) - assert.equal( - config.options.watchOptions.ignored(item.file, item.stats), - item.expected, - item.file - ) - assert.equal(config.options.watchOptions.usePolling, true) - assert.equal(config.options.watchOptions.interval, 71) - } - const defaults = { dirs: ['/repo/src'], options: { ignore: ['**/node_modules/**'] } } - applyOptions(defaults) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/.hidden.ts'), true) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/node_modules/pkg/a.ts'), true) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/a.ts'), false) - const explicitDotfile = { dirs: ['/repo/.env', '/repo/src'], options: { ignore: [] } } - applyOptions(explicitDotfile) - assert.equal(explicitDotfile.options.watchOptions.ignored('/repo/.env'), false) -}) - -test( - 'actual locked watcher restarts TS, env and new source, retains preloads/manual restart, ignores dependencies and stops', - { timeout: 60000 }, - async () => { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-stack-dev-watch-')) - let child, - exited, - output = '', - errors = '', - forced = false - const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] - async function waitFor(predicate, description, remaining = 400) { - if (remaining === 0) throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) - if (predicate()) return - if (child.exitCode !== null || child.signalCode !== null) - throw new Error(`Watcher exited during ${description}: ${errors}`) - await delay(25) - return waitFor(predicate, description, remaining - 1) - } - try { - await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) - await fs.mkdir(path.join(directory, 'src/generated'), { recursive: true }) - await fs.symlink( - path.resolve(__dirname, '../node_modules'), - path.join(directory, 'node_modules'), - 'junction' - ) - await fs.writeFile( - path.join(directory, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - module: 'commonjs', - target: 'es2022', - strict: true, - skipLibCheck: true, - types: ['node'] - } - }) - ) - await fs.writeFile(path.join(directory, '.env'), 'synthetic ordinary configuration') - await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 1') - await fs.writeFile( - path.join(directory, 'src/telemetry.ts'), - "console.log('PRELOAD:' + process.pid)" - ) - const recipe = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')) - .scripts.dev - const prefix = 'node dev/bin/nodemon.js ' - assert.ok(recipe.startsWith(prefix) && recipe.endsWith('"')) - const [watch, execution] = recipe.slice(prefix.length, -1).split(' --exec "') - assert.ok(execution) - const options = watch.split(/\s+/) - const entry = execution.match(/src\/(index|server)\.ts$/)?.[0] - assert.ok(entry) - await fs.writeFile( - path.join(directory, entry), - "import { value } from './dependency'; console.log('READY:' + process.pid + ':' + value); setInterval(() => {}, 1000)" - ) - child = spawn( - process.execPath, - [ - path.join(__dirname, 'bin/nodemon.js'), - ...options, - '--ignore', - 'src/generated/**', - '--exec', - execution - ], - { - cwd: directory, - detached: true, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, CI: '1', NO_UPDATE_NOTIFIER: '1' } - } - ) - exited = new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', (code, signal) => resolve({ code, signal })) - }) - child.stdout.on('data', data => { - output += data.toString() - }) - child.stderr.on('data', data => { - errors += data.toString() - }) - await waitFor(() => starts().length === 1, 'start the synthetic TypeScript process') - await delay(500) - await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 2') - await waitFor(() => starts().length === 2, 'restart for a TypeScript dependency') - assert.equal(starts()[1][2], '2') - await delay(500) - await fs.writeFile(path.join(directory, 'src/node_modules/ignored.js'), 'module.exports = 1') - await fs.writeFile( - path.join(directory, 'src/generated/ignored.ts'), - 'export const ignored = 1' - ) - await delay(700) - assert.equal(starts().length, 2) - await fs.writeFile(path.join(directory, '.env'), 'synthetic changed configuration') - await waitFor(() => starts().length === 3, 'restart for the explicitly watched env file') - await delay(500) - await fs.writeFile(path.join(directory, 'src/new-file.ts'), 'export const newFile = 1') - await waitFor(() => starts().length === 4, 'restart for a new TypeScript source file') - child.stdin.write('rs\n') - await waitFor(() => starts().length === 5, 'accept a manual restart') - if (options.includes('tsconfig.json')) { - await delay(500) - await fs.appendFile(path.join(directory, 'tsconfig.json'), '\n') - await waitFor(() => starts().length === 6, 'restart for the watched compiler configuration') - } - assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) - } finally { - if (child?.exitCode === null && child.signalCode === null) { - process.kill(-child.pid, 'SIGTERM') - const escalation = setTimeout(() => { - forced = true - process.kill(-child.pid, 'SIGKILL') - }, 5000) - try { - await exited - } finally { - clearTimeout(escalation) - } - } - await fs.rm(directory, { recursive: true, force: true }) - } - assert.equal(forced, false, 'the CLI and owned child must exit without forced termination') - } -) diff --git a/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js b/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js deleted file mode 100644 index fe89254b7..000000000 --- a/infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict' - -const path = require('node:path') -const anymatch = require('anymatch') -const isGlob = require('is-glob') -const { bus } = require('nodemon/lib/utils') -const { rulesToMonitor } = require('nodemon/lib/monitor/match') - -function unix(value) { - const slashes = value.replaceAll('\\', '/') - return (slashes.startsWith('//') ? '/' : '') + slashes.replaceAll(/\/{2,}/g, '/') -} -function normalizeIgnored(value, cwd) { - if (typeof value !== 'string') return value - const joined = path.isAbsolute(value) ? value : path.join(cwd ?? '', value) - return unix(path.normalize(unix(joined))) -} - -/** Nodemon already expands watched globs into literal directories. Chokidar4 - * also needs the former glob-aware ignore predicate, including literal-directory - * recursion and custom ignored functions/regexes. Retain its published anymatch - * matcher instead of the removed braces-dependent watch-path expansion. */ -function applyOptions(config) { - const options = config.options.watchOptions ?? {} - let ignored - if (Object.hasOwn(options, 'ignored')) ignored = options.ignored - else { - ignored = rulesToMonitor([], Array.from(config.options.ignore), config).map(pattern => - pattern.slice(1) - ) - const dotFile = /[/\\]\./ - if (!config.dirs.some(directory => dotFile.test(directory))) ignored.push(dotFile) - } - const normalized = (Array.isArray(ignored) ? ignored : [ignored]).map(value => - normalizeIgnored(value, options.cwd) - ) - const directories = normalized - .filter(value => typeof value === 'string' && !isGlob(value)) - .map(value => value + '/**') - const predicate = anymatch([...normalized, ...directories], undefined, { dot: true }) - config.options.watchOptions = { ...options, ignored: (file, stats) => predicate([file, stats]) } -} - -module.exports = applyOptions -if (require.main === module) { - bus.on('config:update', applyOptions) - require('nodemon/bin/nodemon.js') -} diff --git a/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs b/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs deleted file mode 100644 index 7e380ce0e..000000000 --- a/infra/uhrp-server-cloud-bucket/dev/check-watch.cjs +++ /dev/null @@ -1,195 +0,0 @@ -'use strict' - -const assert = require('node:assert/strict') -const fs = require('node:fs/promises') -const { readFileSync } = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { spawn } = require('node:child_process') -const { test } = require('node:test') -const applyOptions = require('./bin/nodemon.js') -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) - -test('development ignore options retain legacy glob, literal-directory, regex, function, cwd and dotfile behavior', () => { - const cases = [ - { ignored: '/repo/cache', file: '/repo/cache/child/file.ts', expected: true }, - { ignored: '/repo/cache', file: '/repo/cache-other/file.ts', expected: false }, - { ignored: 'cache', cwd: '/repo', file: '/repo/cache/file.ts', expected: true }, - { - ignored: String.raw`cache\nested`, - cwd: '/repo', - file: '/repo/cache/nested/file.ts', - expected: true - }, - { ignored: '**/generated/**', file: '/repo/generated/file.ts', expected: true }, - { ignored: '**/generated/**', file: '/repo/src/file.ts', expected: false }, - { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/drop.ts', expected: true }, - { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/keep.ts', expected: false }, - { ignored: /\.map$/, file: '/repo/file.map', expected: true }, - { ignored: /\.map$/, file: '/repo/file.ts', expected: false }, - { - ignored: (_file, stats) => stats?.marker === true, - file: '/repo/a.ts', - stats: { marker: true }, - expected: true - }, - { ignored: (_file, stats) => stats?.marker === true, file: '/repo/a.ts', expected: false }, - { ignored: undefined, file: '/repo/a.ts', expected: false }, - { ignored: [], file: '/repo/a.ts', expected: false } - ] - for (const item of cases) { - const config = { - dirs: ['/repo/src'], - options: { - ignore: [], - watchOptions: { ignored: item.ignored, cwd: item.cwd, usePolling: true, interval: 71 } - } - } - applyOptions(config) - assert.equal( - config.options.watchOptions.ignored(item.file, item.stats), - item.expected, - item.file - ) - assert.equal(config.options.watchOptions.usePolling, true) - assert.equal(config.options.watchOptions.interval, 71) - } - const defaults = { dirs: ['/repo/src'], options: { ignore: ['**/node_modules/**'] } } - applyOptions(defaults) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/.hidden.ts'), true) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/node_modules/pkg/a.ts'), true) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/a.ts'), false) - const explicitDotfile = { dirs: ['/repo/.env', '/repo/src'], options: { ignore: [] } } - applyOptions(explicitDotfile) - assert.equal(explicitDotfile.options.watchOptions.ignored('/repo/.env'), false) -}) - -test( - 'actual locked watcher restarts TS, env and new source, retains preloads/manual restart, ignores dependencies and stops', - { timeout: 60000 }, - async () => { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-stack-dev-watch-')) - let child, - exited, - output = '', - errors = '', - forced = false - const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] - async function waitFor(predicate, description, remaining = 400) { - if (remaining === 0) throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) - if (predicate()) return - if (child.exitCode !== null || child.signalCode !== null) - throw new Error(`Watcher exited during ${description}: ${errors}`) - await delay(25) - return waitFor(predicate, description, remaining - 1) - } - try { - await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) - await fs.mkdir(path.join(directory, 'src/generated'), { recursive: true }) - await fs.symlink( - path.resolve(__dirname, '../node_modules'), - path.join(directory, 'node_modules'), - 'junction' - ) - await fs.writeFile( - path.join(directory, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - module: 'commonjs', - target: 'es2022', - strict: true, - skipLibCheck: true, - types: ['node'] - } - }) - ) - await fs.writeFile(path.join(directory, '.env'), 'synthetic ordinary configuration') - await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 1') - await fs.writeFile( - path.join(directory, 'src/telemetry.ts'), - "console.log('PRELOAD:' + process.pid)" - ) - const recipe = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')) - .scripts.dev - const prefix = 'node dev/bin/nodemon.js ' - assert.ok(recipe.startsWith(prefix) && recipe.endsWith('"')) - const [watch, execution] = recipe.slice(prefix.length, -1).split(' --exec "') - assert.ok(execution) - const options = watch.split(/\s+/) - const entry = execution.match(/src\/(index|server)\.ts$/)?.[0] - assert.ok(entry) - await fs.writeFile( - path.join(directory, entry), - "import { value } from './dependency'; console.log('READY:' + process.pid + ':' + value); setInterval(() => {}, 1000)" - ) - child = spawn( - process.execPath, - [ - path.join(__dirname, 'bin/nodemon.js'), - ...options, - '--ignore', - 'src/generated/**', - '--exec', - execution - ], - { - cwd: directory, - detached: true, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, CI: '1', NO_UPDATE_NOTIFIER: '1' } - } - ) - exited = new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', (code, signal) => resolve({ code, signal })) - }) - child.stdout.on('data', data => { - output += data.toString() - }) - child.stderr.on('data', data => { - errors += data.toString() - }) - await waitFor(() => starts().length === 1, 'start the synthetic TypeScript process') - await delay(500) - await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 2') - await waitFor(() => starts().length === 2, 'restart for a TypeScript dependency') - assert.equal(starts()[1][2], '2') - await delay(500) - await fs.writeFile(path.join(directory, 'src/node_modules/ignored.js'), 'module.exports = 1') - await fs.writeFile( - path.join(directory, 'src/generated/ignored.ts'), - 'export const ignored = 1' - ) - await delay(700) - assert.equal(starts().length, 2) - await fs.writeFile(path.join(directory, '.env'), 'synthetic changed configuration') - await waitFor(() => starts().length === 3, 'restart for the explicitly watched env file') - await delay(500) - await fs.writeFile(path.join(directory, 'src/new-file.ts'), 'export const newFile = 1') - await waitFor(() => starts().length === 4, 'restart for a new TypeScript source file') - child.stdin.write('rs\n') - await waitFor(() => starts().length === 5, 'accept a manual restart') - if (options.includes('tsconfig.json')) { - await delay(500) - await fs.appendFile(path.join(directory, 'tsconfig.json'), '\n') - await waitFor(() => starts().length === 6, 'restart for the watched compiler configuration') - } - assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) - } finally { - if (child?.exitCode === null && child.signalCode === null) { - process.kill(-child.pid, 'SIGTERM') - const escalation = setTimeout(() => { - forced = true - process.kill(-child.pid, 'SIGKILL') - }, 5000) - try { - await exited - } finally { - clearTimeout(escalation) - } - } - await fs.rm(directory, { recursive: true, force: true }) - } - assert.equal(forced, false, 'the CLI and owned child must exit without forced termination') - } -) diff --git a/infra/wab/dev/bin/nodemon.js b/infra/wab/dev/bin/nodemon.js deleted file mode 100644 index fe89254b7..000000000 --- a/infra/wab/dev/bin/nodemon.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict' - -const path = require('node:path') -const anymatch = require('anymatch') -const isGlob = require('is-glob') -const { bus } = require('nodemon/lib/utils') -const { rulesToMonitor } = require('nodemon/lib/monitor/match') - -function unix(value) { - const slashes = value.replaceAll('\\', '/') - return (slashes.startsWith('//') ? '/' : '') + slashes.replaceAll(/\/{2,}/g, '/') -} -function normalizeIgnored(value, cwd) { - if (typeof value !== 'string') return value - const joined = path.isAbsolute(value) ? value : path.join(cwd ?? '', value) - return unix(path.normalize(unix(joined))) -} - -/** Nodemon already expands watched globs into literal directories. Chokidar4 - * also needs the former glob-aware ignore predicate, including literal-directory - * recursion and custom ignored functions/regexes. Retain its published anymatch - * matcher instead of the removed braces-dependent watch-path expansion. */ -function applyOptions(config) { - const options = config.options.watchOptions ?? {} - let ignored - if (Object.hasOwn(options, 'ignored')) ignored = options.ignored - else { - ignored = rulesToMonitor([], Array.from(config.options.ignore), config).map(pattern => - pattern.slice(1) - ) - const dotFile = /[/\\]\./ - if (!config.dirs.some(directory => dotFile.test(directory))) ignored.push(dotFile) - } - const normalized = (Array.isArray(ignored) ? ignored : [ignored]).map(value => - normalizeIgnored(value, options.cwd) - ) - const directories = normalized - .filter(value => typeof value === 'string' && !isGlob(value)) - .map(value => value + '/**') - const predicate = anymatch([...normalized, ...directories], undefined, { dot: true }) - config.options.watchOptions = { ...options, ignored: (file, stats) => predicate([file, stats]) } -} - -module.exports = applyOptions -if (require.main === module) { - bus.on('config:update', applyOptions) - require('nodemon/bin/nodemon.js') -} diff --git a/infra/wab/dev/check-watch.cjs b/infra/wab/dev/check-watch.cjs deleted file mode 100644 index 7e380ce0e..000000000 --- a/infra/wab/dev/check-watch.cjs +++ /dev/null @@ -1,195 +0,0 @@ -'use strict' - -const assert = require('node:assert/strict') -const fs = require('node:fs/promises') -const { readFileSync } = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { spawn } = require('node:child_process') -const { test } = require('node:test') -const applyOptions = require('./bin/nodemon.js') -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) - -test('development ignore options retain legacy glob, literal-directory, regex, function, cwd and dotfile behavior', () => { - const cases = [ - { ignored: '/repo/cache', file: '/repo/cache/child/file.ts', expected: true }, - { ignored: '/repo/cache', file: '/repo/cache-other/file.ts', expected: false }, - { ignored: 'cache', cwd: '/repo', file: '/repo/cache/file.ts', expected: true }, - { - ignored: String.raw`cache\nested`, - cwd: '/repo', - file: '/repo/cache/nested/file.ts', - expected: true - }, - { ignored: '**/generated/**', file: '/repo/generated/file.ts', expected: true }, - { ignored: '**/generated/**', file: '/repo/src/file.ts', expected: false }, - { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/drop.ts', expected: true }, - { ignored: ['**/*.ts', '!**/keep.ts'], file: '/repo/src/keep.ts', expected: false }, - { ignored: /\.map$/, file: '/repo/file.map', expected: true }, - { ignored: /\.map$/, file: '/repo/file.ts', expected: false }, - { - ignored: (_file, stats) => stats?.marker === true, - file: '/repo/a.ts', - stats: { marker: true }, - expected: true - }, - { ignored: (_file, stats) => stats?.marker === true, file: '/repo/a.ts', expected: false }, - { ignored: undefined, file: '/repo/a.ts', expected: false }, - { ignored: [], file: '/repo/a.ts', expected: false } - ] - for (const item of cases) { - const config = { - dirs: ['/repo/src'], - options: { - ignore: [], - watchOptions: { ignored: item.ignored, cwd: item.cwd, usePolling: true, interval: 71 } - } - } - applyOptions(config) - assert.equal( - config.options.watchOptions.ignored(item.file, item.stats), - item.expected, - item.file - ) - assert.equal(config.options.watchOptions.usePolling, true) - assert.equal(config.options.watchOptions.interval, 71) - } - const defaults = { dirs: ['/repo/src'], options: { ignore: ['**/node_modules/**'] } } - applyOptions(defaults) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/.hidden.ts'), true) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/node_modules/pkg/a.ts'), true) - assert.equal(defaults.options.watchOptions.ignored('/repo/src/a.ts'), false) - const explicitDotfile = { dirs: ['/repo/.env', '/repo/src'], options: { ignore: [] } } - applyOptions(explicitDotfile) - assert.equal(explicitDotfile.options.watchOptions.ignored('/repo/.env'), false) -}) - -test( - 'actual locked watcher restarts TS, env and new source, retains preloads/manual restart, ignores dependencies and stops', - { timeout: 60000 }, - async () => { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-stack-dev-watch-')) - let child, - exited, - output = '', - errors = '', - forced = false - const starts = () => [...output.matchAll(/READY:(\d+):(\d+)/g)] - async function waitFor(predicate, description, remaining = 400) { - if (remaining === 0) throw new Error(`Watcher did not ${description}: ${output}\n${errors}`) - if (predicate()) return - if (child.exitCode !== null || child.signalCode !== null) - throw new Error(`Watcher exited during ${description}: ${errors}`) - await delay(25) - return waitFor(predicate, description, remaining - 1) - } - try { - await fs.mkdir(path.join(directory, 'src/node_modules'), { recursive: true }) - await fs.mkdir(path.join(directory, 'src/generated'), { recursive: true }) - await fs.symlink( - path.resolve(__dirname, '../node_modules'), - path.join(directory, 'node_modules'), - 'junction' - ) - await fs.writeFile( - path.join(directory, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - module: 'commonjs', - target: 'es2022', - strict: true, - skipLibCheck: true, - types: ['node'] - } - }) - ) - await fs.writeFile(path.join(directory, '.env'), 'synthetic ordinary configuration') - await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 1') - await fs.writeFile( - path.join(directory, 'src/telemetry.ts'), - "console.log('PRELOAD:' + process.pid)" - ) - const recipe = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')) - .scripts.dev - const prefix = 'node dev/bin/nodemon.js ' - assert.ok(recipe.startsWith(prefix) && recipe.endsWith('"')) - const [watch, execution] = recipe.slice(prefix.length, -1).split(' --exec "') - assert.ok(execution) - const options = watch.split(/\s+/) - const entry = execution.match(/src\/(index|server)\.ts$/)?.[0] - assert.ok(entry) - await fs.writeFile( - path.join(directory, entry), - "import { value } from './dependency'; console.log('READY:' + process.pid + ':' + value); setInterval(() => {}, 1000)" - ) - child = spawn( - process.execPath, - [ - path.join(__dirname, 'bin/nodemon.js'), - ...options, - '--ignore', - 'src/generated/**', - '--exec', - execution - ], - { - cwd: directory, - detached: true, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, CI: '1', NO_UPDATE_NOTIFIER: '1' } - } - ) - exited = new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', (code, signal) => resolve({ code, signal })) - }) - child.stdout.on('data', data => { - output += data.toString() - }) - child.stderr.on('data', data => { - errors += data.toString() - }) - await waitFor(() => starts().length === 1, 'start the synthetic TypeScript process') - await delay(500) - await fs.writeFile(path.join(directory, 'src/dependency.ts'), 'export const value = 2') - await waitFor(() => starts().length === 2, 'restart for a TypeScript dependency') - assert.equal(starts()[1][2], '2') - await delay(500) - await fs.writeFile(path.join(directory, 'src/node_modules/ignored.js'), 'module.exports = 1') - await fs.writeFile( - path.join(directory, 'src/generated/ignored.ts'), - 'export const ignored = 1' - ) - await delay(700) - assert.equal(starts().length, 2) - await fs.writeFile(path.join(directory, '.env'), 'synthetic changed configuration') - await waitFor(() => starts().length === 3, 'restart for the explicitly watched env file') - await delay(500) - await fs.writeFile(path.join(directory, 'src/new-file.ts'), 'export const newFile = 1') - await waitFor(() => starts().length === 4, 'restart for a new TypeScript source file') - child.stdin.write('rs\n') - await waitFor(() => starts().length === 5, 'accept a manual restart') - if (options.includes('tsconfig.json')) { - await delay(500) - await fs.appendFile(path.join(directory, 'tsconfig.json'), '\n') - await waitFor(() => starts().length === 6, 'restart for the watched compiler configuration') - } - assert.equal([...output.matchAll(/PRELOAD:/g)].length, starts().length) - } finally { - if (child?.exitCode === null && child.signalCode === null) { - process.kill(-child.pid, 'SIGTERM') - const escalation = setTimeout(() => { - forced = true - process.kill(-child.pid, 'SIGKILL') - }, 5000) - try { - await exited - } finally { - clearTimeout(escalation) - } - } - await fs.rm(directory, { recursive: true, force: true }) - } - assert.equal(forced, false, 'the CLI and owned child must exit without forced termination') - } -) diff --git a/scripts/dependency-release-governance.test.mjs b/scripts/dependency-release-governance.test.mjs index 06fb9c540..eaf398c71 100644 --- a/scripts/dependency-release-governance.test.mjs +++ b/scripts/dependency-release-governance.test.mjs @@ -22,14 +22,10 @@ test('dependency and release governance is internally complete', () => { assert.deepEqual(validateDependencyReleaseGovernance(), []) const overrides = collectOverrides() - assert.equal(overrides.length, 30) + assert.equal(overrides.length, 27) assert.deepEqual( overrides.filter(entry => entry.selector === 'nodemon'), - ['uhrp-server-basic', 'uhrp-server-cloud-bucket', 'wab'].map(component => ({ - source: `infra/${component}/package.json`, - selector: 'nodemon', - value: { chokidar: '4.0.3' } - })) + [] ) assert.equal(overrides.filter(entry => entry.selector === 'gaxios').length, 8) assert.equal(overrides.filter(entry => entry.selector === 'uuid').length, 3) diff --git a/sonar-project.properties b/sonar-project.properties index f7dfe98ff..e51a435da 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -73,11 +73,7 @@ infra/wab/src/telemetry.ts,\ infra/uhrp-server-basic/src/telemetry.ts,\ infra/uhrp-server-cloud-bucket/src/telemetry.ts,\ infra/wallet-infra/src/telemetry.ts,\ -infra/message-box-server/src/telemetry.ts,\ -infra/uhrp-server-cloud-bucket/dev/bin/nodemon.js,\ -infra/uhrp-server-cloud-bucket/dev/check-watch.cjs,\ -infra/wab/dev/bin/nodemon.js,\ -infra/wab/dev/check-watch.cjs +infra/message-box-server/src/telemetry.ts # Keep CI and Automatic Analysis aligned on the same narrowly registered # compatibility exceptions. sonar.issue.ignore.multicriteria=werrProtocolNames,curveSingletonAlias,curveSingletonReturn,scriptOpcodeDispatch